Guide
Web · Custodial cash-out · Solana / USDC
Custodial off-ramp for web apps: iframe SDK, customerIdentifier sessions, onDepositAddress, and hot-wallet transfers.
MoneyGram Ramps: Custodial Cash-Out (Web / Solana / USDC)
Version 1.0 · Custodial partners · Web iframe SDK · Cash-out only · Solana + USDC · August 2026
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:
- Your backend creates a session with a stable
customerIdentifierfor the logged-in user. - Your frontend embeds the MoneyGram widget with
walletType: 'custodial'and anonDepositAddresscallback. - The widget handles KYC, quoting, and transaction commit.
- 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. - 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)
| Dimension | Non-custodial | Custodial (this guide) |
|---|---|---|
| Who holds keys | End user | Partner |
| Customer identity | Wallet public key | customerIdentifier (your internal user ID) |
| On-chain address | Unique per user | Shared omnibus / hot wallet |
| Widget callback | onSignTransaction | onDepositAddress |
| Session requirement | Optional walletAddress | customerIdentifier required |
| Cash-out flow | User signs USDC transfer | Partner 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:
| What | Value |
|---|---|
| Omnibus wallet | custodianCashOutWallet, the Solana address that sends USDC to MoneyGram. This is the wallet.address you pass to createRamps. |
| Widget mode | off-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.
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:
<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.
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):
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):
- Resolve the customer's active cash-out transaction and verify destination, asset, chain, and amount against the Ramps API.
- Validate the amount and verify the customer has sufficient internal balance (your ledger).
- Debit the customer's internal balance.
- Transfer USDC from your hot wallet (
custodianCashOutWallet) to the verified deposit address (include the memo if present). - Return the on-chain transaction signature (txHash) as a string from the callback.
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:
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.
// 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.
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# ───│◀───────────────────│ │
Before requesting production access:
- Partner account registered as custodial (
walletCustody) -
custodianCashOutWalletconfigured and funded with testnet USDC - Session endpoint sends
customerIdentifierfor every user -
onDepositAddressimplemented (returns txHash on success) -
onSignTransactionnot used for custodial cash-out - Internal balance debit + hot-wallet transfer tested end-to-end in sandbox
-
onCompletereceivesreferenceNumberafter deposit confirmation - Error path tested (insufficient balance, transfer failure)
- Allowed origins configured in partner portal
- Secret key never exposed in client code
| Symptom | Likely cause |
|---|---|
400 customerIdentifier is required | Session created without customerIdentifier on a custodial partner |
| Widget stuck on "Transfer in progress…" | onDepositAddress did not return a txHash |
| 403 on session creation | Used public key (ramps_pk_*) instead of secret key |
| Wrong customer KYC prefill | customerIdentifier not stable across sessions for the same user |
| Transfer rejected on-chain | Wrong USDC mint, insufficient hot-wallet balance, or missing memo |
| Guide | Use when |
|---|---|
| Custodial integration overview | Shared setup, sessions, profiles, security, and the cash-out vs cash-in comparison |
| React Native · Custodial cash-out | Mobile 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 · USDC | Stellar SEP-24 integration (separate custody model) |
- Custodial cash-in (on-ramp): Custodial cash-in guide (omnibus destination, widget on-ramp, webhook crediting).
- Transaction status: Transaction status & webhooks (polling and partner webhooks for cash-in and cash-out).