Guide

Web · Custodial cash-out · Stellar / USDC

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

1–2 hoursCustodialStellarUSDC · off-ramp

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

Preview · Custodial partners · Web iframe SDK · Cash-out only · Stellar + USDC

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 · Stellar (non-custodial) guide.

Scope of this guide: Custodial cash-out (off-ramp) only. For custodial cash-in, use the Custodial cash-in guide. 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 (include memo when required).
  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: Stellar
Asset: USDC
Integration time: 1–2 hours (including your internal transfer orchestration)


DimensionNon-custodialCustodial (this guide)
Who holds keysEnd userPartner
Customer identityWallet G-addresscustomerIdentifier (your internal user ID)
On-chain addressUnique per userShared omnibus / hot wallet
Widget callbackonSignTransactiononDepositAddress
Session requirementwalletAddress recommendedcustomerIdentifier required
Cash-out flowUser signs USDC paymentPartner backend transfers from hot wallet

Your custody model (walletCustody) is set at partner onboarding. You cannot switch between custodial and non-custodial per session.


Custodial sessions require a customerIdentifier: a stable opaque string that uniquely identifies the end customer across transactions. Max 255 characters.

TypeScript
async function createCustodialSession(req, res) {  const secretKey = process.env.MONEYGRAM_SK // ramps_sk_sbox_...  if (!secretKey) return res.status(500).json({ error: 'Secret key not configured' })   const customerIdentifier = req.user.id // from your auth middleware   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,      chain: 'stellar',    }),  })   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,    widgetUrl: data.widgetUrl,  })}

Request fields (custodial):

FieldRequiredDescription
customerIdentifierYesStable internal user ID (max 255 chars)
walletAddressRecommendedYour omnibus / hot wallet G-address
chainOptional'stellar'

Common errors:

StatusCause
400Missing customerIdentifier for a custodial partner
403Public key used instead of secret key
401Invalid or revoked API key

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

Pass walletType: 'custodial' 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 = 'G...YourHotWallet...' const ramps = createRamps({  container: document.getElementById('ramps-widget'),  sessionToken,  widgetUrl,   wallet: {    address: PARTNER_OMNIBUS_WALLET,    chain: 'stellar',    asset: 'USDC',    walletType,  },   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  },   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.

Never trust browser-supplied transfer parameters. Your backend resolves the customer's active cash-out transaction and verifies destination, asset, chain, amount, and memo against the Ramps API before moving funds.


Payload shape (DepositAddress):

TypeScript
interface DepositAddress {  address: string // MoneyGram deposit G-address  memo?: string // Required when present: include in Stellar payment  chain: 'stellar'  asset: 'USDC'  amount?: string}

Your responsibilities (server-side):

  1. Resolve the customer's active cash-out transaction and verify destination, asset, chain, amount, and memo against the Ramps API.
  2. Validate amount and verify sufficient internal balance.
  3. Debit the customer's internal balance.
  4. Transfer USDC from your hot wallet to the verified deposit address (include memo when present).
  5. Return the on-chain transaction hash as a string from the callback.

The widget polls /v1/transactions/:id/check-deposit until MoneyGram confirms the on-chain deposit.


Implement a server-side endpoint your frontend calls from onDepositAddress. The endpoint takes no transfer parameters from the browser. It reads authoritative values from:

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

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

Make the endpoint idempotent: key a durable transfer record on the Ramps transaction id and return the stored result on retry.

Example Stellar transfer (server-side):

TypeScript
import { Asset, Horizon, Keypair, Memo, Networks, Operation, TransactionBuilder } from '@stellar/stellar-sdk' /** Settlement memos are Stellar ID memos (unsigned 64-bit), not text memos. */function settlementMemo(memo: string) {  if (!/^[0-9]+$/.test(memo)) {    throw new Error('Settlement memo must be a numeric Stellar ID memo')  }  return Memo.id(memo)} async function hotWalletSendUsdc(params: {  to: string  amount: string  memo?: string  issuer: string  network: 'mainnet' | 'testnet'}): Promise<string> {  const horizon =    params.network === 'mainnet'      ? new Horizon.Server('https://horizon.stellar.org')      : new Horizon.Server('https://horizon-testnet.stellar.org')  const networkPassphrase = params.network === 'mainnet' ? Networks.PUBLIC : Networks.TESTNET  const source = Keypair.fromSecret(process.env.HOT_WALLET_SECRET!)  const account = await horizon.loadAccount(source.publicKey())  const usdc = new Asset('USDC', params.issuer)   let builder = new TransactionBuilder(account, { fee: '100', networkPassphrase }).addOperation(    Operation.payment({ destination: params.to, asset: usdc, amount: params.amount }),  )  if (params.memo?.trim()) builder = builder.addMemo(settlementMemo(params.memo.trim()))   const tx = builder.setTimeout(180).build()  tx.sign(source)  const result = await horizon.submitTransaction(tx)  return result.hash}

Pass issuer for params.network. The testnet Circle issuer must not be used when network is mainnet. When depositMemo is present, settlementMemo attaches it as Memo.id (a numeric string). A text memo does not match the deposit record.