Guide
Web · Custodial cash-out · Stellar / USDC
Custodial off-ramp for web apps: iframe SDK, customerIdentifier sessions, onDepositAddress, and hot-wallet Stellar USDC transfers.
MoneyGram Ramps: Custodial Cash-Out (Web / Stellar / USDC)
Preview · Custodial partners · Web iframe SDK · Cash-out only · Stellar + USDC
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:
- 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 (include memo when required). - 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)
| Dimension | Non-custodial | Custodial (this guide) |
|---|---|---|
| Who holds keys | End user | Partner |
| Customer identity | Wallet G-address | customerIdentifier (your internal user ID) |
| On-chain address | Unique per user | Shared omnibus / hot wallet |
| Widget callback | onSignTransaction | onDepositAddress |
| Session requirement | walletAddress recommended | customerIdentifier required |
| Cash-out flow | User signs USDC payment | Partner 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.
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):
| Field | Required | Description |
|---|---|---|
customerIdentifier | Yes | Stable internal user ID (max 255 chars) |
walletAddress | Recommended | Your omnibus / hot wallet G-address |
chain | Optional | 'stellar' |
Common errors:
| Status | Cause |
|---|---|
| 400 | Missing customerIdentifier for a custodial partner |
| 403 | Public key used instead of secret key |
| 401 | Invalid or revoked API key |
<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.
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):
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):
- Resolve the customer's active cash-out transaction and verify destination, asset, chain, amount, and memo against the Ramps API.
- Validate amount and verify sufficient internal balance.
- Debit the customer's internal balance.
- Transfer USDC from your hot wallet to the verified deposit address (include memo when present).
- 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:
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):
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.