Guide

Web · Custodial cash-out · Solana / USDC

Custodial off-ramp for web apps: iframe SDK, customerIdentifier sessions, onDepositAddress, and hot-wallet transfers.

1–2 hoursCustodialSolanaUSDC · off-ramp

MoneyGram Ramps: Custodial Cash-Out (Web / Solana / USDC)

Version 1.0 · Custodial partners · Web iframe SDK · Cash-out only · Solana + USDC · August 2026

Overview

This guide is for custodial partners embedding the widget in a web app via the iframe SDK (createRamps). You hold customer USDC in a shared omnibus or hot wallet; end users do not sign blockchain transactions themselves.

For React Native, use the React Native · Custodial cash-out guide.

For non-custodial web integrations (onSignTransaction), use the Web · Solana (non-custodial) guide.

Scope of this guide: Custodial cash-out (off-ramp) only. For custodial cash-in, use the Custodial cash-in guide (shared across Web and React Native).

Read first: the Custodial integration overview covers the setup shared by both custodial flows: custody mode, omnibus wallets, API keys, environments, sessions, profile APIs, and the security rules. This guide covers only what is specific to web cash-out. Host setup is Embed the widget.

How custodial cash-out works:

  1. Your backend creates a session with a stable customerIdentifier for the logged-in user.
  2. Your frontend embeds the MoneyGram widget with walletType: 'custodial' and an onDepositAddress callback.
  3. The widget handles KYC, quoting, and transaction commit.
  4. When MoneyGram allocates a deposit address, the widget calls onDepositAddress: your app debits the customer's internal balance and transfers USDC from your hot wallet to that address.
  5. You return the on-chain transaction hash; the widget polls until the transfer is confirmed and issues a reference number for cash pickup.

Chain: Solana
Asset: USDC (standard SPL mint, not Token-2022)
Integration time: 1–2 hours (including your internal transfer orchestration)


DimensionNon-custodialCustodial (this guide)
Who holds keysEnd userPartner
Customer identityWallet public keycustomerIdentifier (your internal user ID)
On-chain addressUnique per userShared omnibus / hot wallet
Widget callbackonSignTransactiononDepositAddress
Session requirementOptional walletAddresscustomerIdentifier required
Cash-out flowUser signs USDC transferPartner backend transfers from hot wallet

Important: Your custody model (walletCustody) is set at partner onboarding and stored on your partner record. You cannot switch between custodial and non-custodial per session: the API derives walletType from your registered configuration.


Complete the shared custodial setup in the Custodial integration overview: custody mode (walletCustody: 'custodial'), API keys, allowed origins, KYB, and environments.

Two things are specific to cash-out:

WhatValue
Omnibus walletcustodianCashOutWallet, the Solana address that sends USDC to MoneyGram. This is the wallet.address you pass to createRamps.
Widget modeoff-ramp, already set on the widgetUrl the session API returns. Do not hardcode it.

custodianCashInWallet is for on-ramp and is not used in this guide.


Custodial sessions require a customerIdentifier: a stable opaque string (UUID, hashed email, internal user ID) that uniquely identifies the end customer across transactions. Max 255 characters.

The identifier is embedded in the session JWT server-side. The widget cannot forge or change it.

TypeScript
async function createCustodialSession(req, res) {  const origin = req.headers.origin  const allowedOrigins = ['https://yourapp.com', 'http://localhost:3000']  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_...  if (!secretKey) return res.status(500).json({ error: 'Secret key not configured' })   // Authenticated user from your auth layer, NOT from the client body alone  const customerIdentifier = req.user.id  // e.g. UUID, stable internal ID   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({      customerIdentifier,      walletAddress: process.env.PARTNER_OMNIBUS_WALLET,  // your custodianCashOutWallet      chain: 'solana',      // Optional: correlate this flow with your own transaction reference      // walletTransactionId: '550e8400-e29b-41d4-a716-446655440000',    }),  })   const data = await mgiRes.json()  if (!mgiRes.ok) return res.status(mgiRes.status).json(data)   return res.status(200).json({    sessionToken: data.sessionToken,    sessionId:    data.sessionId,    walletType:   data.walletType,   // 'custodial'    widgetUrl:    data.widgetUrl,     // includes ?mode=off-ramp, do not hardcode  })}

Field reference, response shape, error codes, and session TTL are in the Custodial overview → Sessions. For cash-out, pass your custodianCashOutWallet as walletAddress.

Security: derive customerIdentifier from your authenticated session. Never trust a client-supplied user ID without verification.


Same as the non-custodial web guide. Add the SDK via script tag:

HTML
<script src="https://playground.xramps.moneygram.com/sdk/index.global.js"></script><script>  const { createRamps } = window.RampsSDK</script>

See the Web · Solana guide for npm/bundler setup if you prefer a module import.


Pass walletType: 'custodial' from the session response and implement onDepositAddress instead of onSignTransaction.

TypeScript
const { sessionToken, walletType, widgetUrl } = await fetch('/api/ramps-session', {  method: 'POST',}).then((r) => r.json()) const PARTNER_OMNIBUS_WALLET = 'YourSolanaHotWalletAddress...' const ramps = createRamps({  container: document.getElementById('ramps-widget'),  sessionToken,  widgetUrl,   wallet: {    address: PARTNER_OMNIBUS_WALLET,    chain: 'solana',    asset: 'USDC',    walletType,  // 'custodial' from session, do not hardcode unless testing  },   transaction: {    type: 'off-ramp',    destinationCountry: 'MEX',  // optional pre-fill  },   // Custodial: partner backend moves funds when deposit address is ready.  // Treat the browser payload as informational only. Your backend resolves  // the active Ramps transaction for this customer and re-reads the  // destination, asset, chain, and amount from the Ramps API (Step 5)  // before moving any funds.  onDepositAddress: async () => {    const res = await fetch('/api/internal-transfer', { method: 'POST' })    if (!res.ok) throw new Error('Transfer failed')    const { txHash } = await res.json()    return txHash  // required: widget polls using this hash  },   onComplete: (tx) => {    console.log('Cash-out complete', tx.referenceNumber)  },   onError: (err) => {    console.error('Ramps error', err.reason)  },}) ramps.open()

Do not implement onSignTransaction for custodial cash-out. The widget emits RAMPS_DEPOSIT_ADDRESS and waits for your transfer, not a user wallet signature.

Never trust browser-supplied transfer parameters. Anything the browser sends to /api/internal-transfer can be forged. The RAMPS_DEPOSIT_ADDRESS payload does not include a transaction id, so your backend resolves the customer's active cash-out transaction itself and verifies the destination, asset, chain, and amount against the Ramps API before moving funds (see Step 5).

CSRF: If /api/internal-transfer is authenticated by a session cookie, it needs CSRF protection (a CSRF token or same-site cookie policy), like any other state-changing endpoint.


When the user commits the transaction, MoneyGram returns a deposit address. The widget forwards it to your host app:

Payload shape (DepositAddress):

TypeScript
interface DepositAddress {  address: string       // MoneyGram deposit address (transfer USDC here)  memo?: string         // Memo if required by the deposit instruction  chain: 'solana'  asset: 'USDC'  amount?: string       // Optional: amount to transfer (stringified)}

Note that amount is optional in the payload contract and, like every other field, arrives via the browser. Your backend must validate that the amount it acts on exists and is a positive USDC decimal string, and it must take the authoritative values from the Ramps API (Step 5), not from this payload. The payload does not include a transaction id.

Your responsibilities (server-side, per Step 5):

  1. Resolve the customer's active cash-out transaction and verify destination, asset, chain, and amount against the Ramps API.
  2. Validate the amount and verify the customer has sufficient internal balance (your ledger).
  3. Debit the customer's internal balance.
  4. Transfer USDC from your hot wallet (custodianCashOutWallet) to the verified deposit address (include the memo if present).
  5. Return the on-chain transaction signature (txHash) as a string from the callback.
TypeScript
onDepositAddress: async () => {  // All verification, balance checks, and fund movement happen server-side  const txHash = await callInternalTransferApi()  // POST /api/internal-transfer  return txHash  // SDK sends RAMPS_SIGN_SUCCESS to the widget with this hash}

If the callback throws or rejects, the widget shows an error to the user. If it resolves without a txHash, the widget keeps waiting. Always return the signature string on success.

The widget then polls /v1/transactions/:id/check-deposit until MoneyGram confirms the on-chain deposit, then completes with a reference number for cash pickup.


Implement a server-side endpoint your frontend calls from onDepositAddress. This is your infrastructure. Ramps does not move funds on your behalf.

The endpoint takes no transfer parameters from the browser. It resolves the customer's active cash-out transaction, then reads the authoritative destination, asset, chain, and amount from the Ramps API with your secret key:

TEXT
GET /v1/transactions/:id/statusx-api-key: ramps_sk_...

The response includes status, depositAddress, depositMemo, sendAmount, and asset for the transaction. Only a transaction in awaiting_funds status should trigger a transfer.

The widget can retry the callback (page reloads, transient network errors), so the endpoint must be idempotent: key a durable transfer record on the Ramps transaction id and return the stored result on retry, so each transaction is paid out at most once. The debit must also be atomic (reserve funds only if the balance is sufficient, in one operation) and reversible if the on-chain send fails.

TypeScript
// POST /api/internal-transferapp.post('/api/internal-transfer', async (req, res) => {  const userId = req.user.id  // from your auth middleware; nothing read from req.body   // 1. Resolve the customer's active cash-out transaction. For example, store  //    the Ramps transactionId against the user when the widget session starts,  //    or look up the pending transfer you recorded for this customer.  const transactionId = await getActiveRampsTransactionId(userId)  if (!transactionId) {    return res.status(404).json({ error: 'No active cash-out transaction' })  }   // 2. Idempotency: one durable transfer record per Ramps transaction id.  //    On retry, return the stored result instead of paying out twice.  const existing = await findTransferByRampsTransactionId(transactionId)  if (existing) {    return res.json({ txHash: existing.txHash })  }   // 3. Verify the transfer against the Ramps API (server API key, not browser input)  const tx = await fetch(`${RAMPS_API}/v1/transactions/${transactionId}/status`, {    headers: { 'x-api-key': process.env.MONEYGRAM_SK },  }).then((r) => r.json())   if (tx.status !== 'awaiting_funds' || tx.asset !== 'USDC' || !tx.depositAddress) {    return res.status(409).json({ error: 'Transaction is not awaiting funds' })  }   // 4. Validate the amount before touching any balance: it must exist and be  //    a positive USDC decimal string.  const amount = tx.sendAmount  if (typeof amount !== 'string' || !/^\d+(\.\d{1,6})?$/.test(amount) || parseFloat(amount) <= 0) {    return res.status(422).json({ error: 'Invalid transaction amount' })  }   // 5. Atomically reserve the funds: a single conditional ledger operation  //    that debits only if the balance is sufficient (for example, a  //    conditional UPDATE or a ledger hold). Never check-then-debit in two steps.  const reservation = await reserveCustomerUsdc(userId, amount, transactionId)  if (!reservation.ok) {    return res.status(400).json({ error: 'Insufficient balance' })  }   // 6. Send from the hot wallet; reverse the reservation on failure.  try {    const txHash = await hotWallet.sendUsdc({      to: tx.depositAddress,      memo: tx.depositMemo ?? undefined,      amount,      // Use mainnet or devnet mint per your sandbox/production environment    })    await recordTransfer({ transactionId, userId, amount, txHash })  // completes the idempotency record    return res.json({ txHash })  } catch (err) {    await releaseCustomerUsdc(reservation.id)  // compensate: give the funds back    return res.status(502).json({ error: 'On-chain transfer failed' })  }})

This is a guide sample, not production code: in production the idempotency record, the reservation, and the send outcome belong in the same durable store, and a stuck reservation (crash between send and record) needs a reconciliation job that checks the chain before releasing or completing it.

Operational checklist:

  • Hot wallet must hold enough USDC for concurrent transactions.
  • Use the correct USDC mint for your environment (devnet vs mainnet).
  • Include memo when MoneyGram provides one.
  • Do not log the raw customerIdentifier. Log the amount, txHash, and Ramps transaction ID together with a redacted or separately generated reconciliation ID that maps to the customer in a restricted system, and apply access and retention controls to those logs.
  • Handle partial failures: if on-chain send fails after debiting, implement your own reversal/compensation logic.

Rather than hardcoding the omnibus address in your frontend, fetch it at runtime with GET /v1/partners/config and use custodianCashOutWallet as the wallet.address in createRamps. Request shape and full response: Custodial overview → Partner config API.


Custodial profile lookup keys on customerIdentifier rather than a wallet address, so the widget prefills KYC automatically and you can pre-create profiles server-side. Endpoints and behaviour: Custodial overview → Profile APIs.


End-to-end flow

TEXT
Your backend          Your frontend           Ramps widget / SDK        Ramps API           MGI     │                      │                         │                    │                │     │ POST /v1/sessions    │                         │                    │                │     │ (sk, customerId)     │                         │                    │                │     │◀─ sessionToken ──────│                         │                    │                │     │                      │ createRamps(custodial)  │                    │                │     │                      │────────────────────────▶│                    │                │     │                      │                         │ quote, KYC, commit │                │     │                      │                         │───────────────────▶│───────────────▶│     │                      │◀── onDepositAddress ────│◀─ deposit address ─│◀───────────────│     │◀─ POST /internal-transfer                      │                    │                │     │ hot wallet ─ USDC ──────────────────────────────────────────────────────────────────▶│     │── txHash ───────────▶│ (callback return)       │                    │                │     │                      │                         │ poll check-deposit │                │     │                      │◀── onComplete + ref# ───│◀───────────────────│                │

Testing checklist

Before requesting production access:

  • Partner account registered as custodial (walletCustody)
  • custodianCashOutWallet configured and funded with testnet USDC
  • Session endpoint sends customerIdentifier for every user
  • onDepositAddress implemented (returns txHash on success)
  • onSignTransaction not used for custodial cash-out
  • Internal balance debit + hot-wallet transfer tested end-to-end in sandbox
  • onComplete receives referenceNumber after deposit confirmation
  • Error path tested (insufficient balance, transfer failure)
  • Allowed origins configured in partner portal
  • Secret key never exposed in client code

Troubleshooting

SymptomLikely cause
400 customerIdentifier is requiredSession created without customerIdentifier on a custodial partner
Widget stuck on "Transfer in progress…"onDepositAddress did not return a txHash
403 on session creationUsed public key (ramps_pk_*) instead of secret key
Wrong customer KYC prefillcustomerIdentifier not stable across sessions for the same user
Transfer rejected on-chainWrong USDC mint, insufficient hot-wallet balance, or missing memo

GuideUse when
Custodial integration overviewShared setup, sessions, profiles, security, and the cash-out vs cash-in comparison
React Native · Custodial cash-outMobile app with WebView bridge
Web · Solana (non-custodial)End users sign with their own wallet
React Native · Solana (non-custodial)Mobile non-custodial integration
Stellar · USDCStellar SEP-24 integration (separate custody model)