Reference
Transaction status & webhooks
Widget events, status polling, and MoneyGram status webhooks for cash-in and cash-out (custodial and non-custodial).
Transaction status & webhooks
How partners learn when a Ramps transaction moves forward: widget events, status polling, and MoneyGram status webhooks. These mechanisms apply to all flows (cash-in and cash-out, custodial and non-custodial).
For custodial cash-in crediting (mapping customerIdentifier to omnibus delivery), see Custodial cash-in.
For the RAMPS_TRANSACTION_CREATED widget event payload, see RAMPS_TRANSACTION_CREATED.
Optional · after validate: Web onTransactionCreated / React Native RAMPS_TRANSACTION_CREATED fires once validate succeeds (before commit). Use it to persist the Ramps transaction id early for view mode and mgiTransactionId for webhook correlation. Full timing, payload, and examples: onTransactionCreated.
| Mechanism | Best for | Applies to |
|---|---|---|
Widget events (RAMPS_TRANSACTION_*) | Embedded Web / RN apps | Widget integrations |
Status polling (GET /status?sync=true) | Server-side apps, backup, reconciliation | All flows |
| MoneyGram status webhooks | Server-to-server push | All flows (same partner URL) |
| Area | Status |
|---|---|
Widget RAMPS_TRANSACTION_CREATED (includes mgiTransactionId) | Implemented |
| Status polling API | Implemented |
| Partner webhook URL configuration (partner portal) | Implemented |
Webhook payload, Stellar (XLM) | Live, unchanged |
Webhook payload, Solana (SOL) | Live. Documented below |
┌─────────────────────────────────────┐ │ Ramps transaction (any flow) │ └─────────────────────────────────────┘ │ ┌───────────────────────────┼───────────────────────────┐ ▼ ▼ ▼ Widget events Status polling Status webhooks (embedded SDK) (server-side / backup) (MoneyGram → partner URL) │ │ │ RAMPS_TRANSACTION_CREATED GET /status?sync=true server-to-server POST RAMPS_TRANSACTION_COMPLETE
They are complementary, but they are not equally suited to every job. Use widget events for the user experience and webhooks as the server-side default: they arrive when something actually changes, rather than asking repeatedly whether it has. Use polling as the fallback where webhooks are not yet enabled, and as the reconciliation path for anything a webhook did not deliver.
As the user moves through a flow inside the MoneyGram widget, the SDK posts events to the host page (Web) or WebView (React Native). Not every event is terminal. RAMPS_TRANSACTION_CREATED fires as soon as validate succeeds, before commit, so handle it when it arrives rather than waiting for the flow to finish.
| Event | Cash-in | Cash-out |
|---|---|---|
RAMPS_TRANSACTION_CREATED | After validate succeeds | After validate succeeds |
RAMPS_TRANSACTION_COMPLETE | Done screen (confirmation code) | Done screen (reference number) |
RAMPS_TRANSACTION_FAILED | Error path | Error path |
RAMPS_SIGN_TRANSACTION | Not used | Non-custodial only |
RAMPS_DEPOSIT_ADDRESS | Not used | Custodial only |
See RAMPS_TRANSACTION_CREATED for when the creation event fires relative to validate and commit, and for the payload (id, mgiTransactionId, quote economics).
Important: RAMPS_TRANSACTION_COMPLETE fires when the widget reaches the done screen. It does not always mean the transaction is terminal (for cash-in, the user still pays at the store). Use webhooks, or polling where webhooks are not yet enabled, for completed, paid_out, and the other statuses below.
GET /v1/transactions/{id}/status?sync=trueAuthorization: Bearer {sessionToken}
sync=true refreshes from MoneyGram before returning. Use when the user is viewing a receipt or your backend needs authoritative state.
When to poll
If you embed the widget, start polling once you have the Ramps transaction id from RAMPS_TRANSACTION_CREATED. The widget drives the flow itself, so the steps named in the Start column happen inside it. If you drive the API directly, start at the step named.
| Flow | Start | Stop when |
|---|---|---|
| Cash-in | After commit | completed, failed, or quote_expired |
| Cash-out (non-custodial) | After check-deposit succeeds | completed if you only need to know the cash is ready to collect, paid_out if you need to know it was collected. Always stop on failed, quote_expired, or a terminal refund status (refunded or refund_failed). Do not stop on funds_received. |
| Cash-out (custodial) | After your hot-wallet transfer is confirmed | Same as cash-out (non-custodial) |
On a cash-out, funds_received means MoneyGram has received the USDC. It does not mean the customer can collect cash yet. The reference number goes live at completed, and paid_out follows once the customer collects the cash at the agent. If you stop at funds_received, you never learn that the pickup became available, and you do not see a later failure or refund.
Cash-in status phases
| Phase | Typical status |
|---|---|
| After validate | validated / pending_kyc |
| After commit | committed |
| User paid at counter | funds_received |
| USDC delivered | completed |
| Window expired | quote_expired |
Cash-out status phases
| Phase | Typical status | Notes |
|---|---|---|
| Awaiting consumer commit | pending_commit | Commit still in flight |
| Awaiting USDC | awaiting_funds | Sign & Send window open, or deposit not yet confirmed at MoneyGram |
| Deposit submitted, confirmation pending | awaiting_funds | Customer already signed and sent on-chain. The widget shows a confirmation pending state (up to 20 minutes) instead of prompting Sign & Send again. Poll GET /status for depositSubmitted, depositConfirmationRemainingMs, and related fields. |
| USDC received | funds_received | On-chain deposit confirmed |
| Ready for pickup | completed (cash-out) | Pickup code active |
| Picked up | paid_out | Cash collected at agent |
| Refund lifecycle | refund_requested, refund_mgi_pending, refund_mgi_success, refunded, refund_failed | Cash-out only. Poll until a terminal refund status. |
Refunds are cash-out only. Cash-in has no wallet refund path after commit.
What Ramps provides today
Partners configure sandbox and production webhook URLs in the partner portal:
Where to configure: Partner portal → Settings → Integration
When you save a production URL, Ramps registers it with MoneyGram so status events can be delivered to your endpoint. Ramps stores your URL and coordinates registration. MoneyGram's notification service POSTs status events to your URL.
Availability
| Environment | When the webhook URL field is available |
|---|---|
| Sandbox | After sandbox onboarding is complete |
| Production | After KYB approval and go-live. Contact your MoneyGram integration contact if production webhooks are not yet enabled. |
Production webhook URLs are locked in the partner portal until KYB is approved.
Correlation key: mgiTransactionId
Store mgiTransactionId when the transaction is created:
- Widget:
RAMPS_TRANSACTION_CREATED.mgiTransactionId(after validate succeeds) - Server-side:
mgiTransactionIdfrom the quote or validate API response when you drive the flow from your backend
When a webhook arrives, look up your pending record by mgiTransactionId, then confirm with GET /v1/transactions/{rampsTransactionId}/status?sync=true before acting.
Recommended handler pattern
1. At transaction creation: persist { mgiTransactionId, rampsTransactionId, flow, amount, ... }.2. Register your webhook URL in Settings → Integration (sandbox first, then production after go-live).3. On webhook receipt: a. Look up pending row by mgiTransactionId. b. GET /v1/transactions/{rampsTransactionId}/status?sync=true. c. Act on confirmed status (notify user, credit ledger, mark picked up, etc.).
For custodial cash-in crediting, add customerIdentifier from your session to the pending row. See Custodial cash-in · Crediting pattern.
Webhook payload
There are two payload shapes. Which one you receive depends on the network your transaction settles on.
| Network | Payload |
|---|---|
Stellar (XLM) | Stellar payload. Unchanged, and live since launch. |
Solana (SOL) | Solana payload, described below. |
If you are live on Stellar today, nothing changes and no action is required. The Stellar payload and its field names are untouched.
If you integrate on Solana, or on both networks, use the network field as the discriminator: it is present on Solana notifications and absent on Stellar notifications. Branch on it rather than on account formats or amounts.
Shape
The notification is {"transaction": { ... }}, the same as the Stellar payload. Fields with no value are omitted rather than sent as null, so treat most fields as optional.
The transport wraps it once more: the request body your endpoint receives is {"message": "<this JSON as a string>"}. Every example below shows the notification after you parse message. See Verifying the signature, because the signature covers the string, not the request body.
Required fields differ by network, so do not validate both shapes against one schema:
| Network | Always present |
|---|---|
Solana (SOL) | id, network, kind, status |
Stellar (XLM) | id, kind, status. Stellar notifications never carry network. |
A shared validator that requires network will reject every valid Stellar notification.
Cash-out (withdrawal) on Solana:
{ "transaction": { "id": "12d6a8c1-9d7b-4dd1-9b4a-7b4169885fad", "network": "SOL", "transaction_id": "5wHTKxhvjBfXMyfySQXTsCYqw9YXhMMXVqzjweEvAjxytgHLfLVF3Xs7BS5UT6VTaMWkzYbbwmkDPJn6tzaPqmCM", "external_transaction_id": "11370841", "kind": "withdrawal", "status": "pending_user_transfer_complete", "amount_in": "6.66", "amount_in_asset": "USDC", "amount_out": "6.66", "amount_out_asset": "USD", "amount_fee": "0.00", "amount_fee_asset": "USD", "from": "FiFhQFxvNB8Cxjyuym5ZGYAJHuApdptCEbA6ymhi61FB", "to": "GAYF33NNNMI2Z6VNRFXQ64D4E4SF77PM46NW3ZUZEEU5X7FCHAZCMHKU", "started_at": "2026-08-14T18:50:50Z", "updated_at": "2026-08-14T18:52:03Z" }}
Cash-in (deposit) before settlement. No on-chain transfer exists yet, so transaction_id is absent:
{ "transaction": { "id": "5ab9826f-4944-4df6-a6df-03aad8cd471a", "network": "SOL", "external_transaction_id": "69908343", "kind": "deposit", "status": "pending_anchor", "amount_in": "10.76", "amount_in_asset": "USD", "amount_out": "10.76", "amount_out_asset": "USDC", "from": "AHRNNSgPtvPJqPEC2a5HqbWcYj7uyADw3kJrk8TnvpkA", "to": "GAYF33NNNMI2Z6VNRFXQ64D4E4SF77PM46NW3ZUZEEU5X7FCHAZCMHKU" }}
Fields
| Field | Notes |
|---|---|
id | MoneyGram transaction id. This is the mgiTransactionId you stored at transaction creation, and it is your correlation key. |
network | Network code, SOL for Solana. Present on Solana notifications only, so it is the reliable way to tell the two shapes apart. |
transaction_id | On-chain transaction hash or signature. Absent until a settling transfer exists on chain, so expect it to be missing on early events. |
external_transaction_id | MoneyGram reference number, or the confirmation number when no reference number is set. |
kind | deposit for cash-in, withdrawal for cash-out. |
amount_in, amount_in_asset | Amount and asset the customer sends. |
amount_out, amount_out_asset | Amount and asset the customer receives. |
amount_fee, amount_fee_asset | Fee and the asset the fee is denominated in. |
from | Customer account for a cash-out. |
to | MoneyGram receiving account as recorded on the transaction. Do not use it to infer the network, use network. |
started_at, updated_at, completed_at | Timestamps in yyyy-MM-dd'T'HH:mm:ss'Z' format. The trailing Z is literal: the value is rendered in US Central time, not UTC, and a known conversion issue can also shift the value. Treat these as informational and do not use them for ordering or for age checks. completed_at appears only once the transaction reaches a terminal state. |
status | Transaction status. See the note below. |
All amounts are strings, not numbers. Parse them as decimals, and do not round-trip them through a float.
Differences from the Stellar payload
If you are porting a working Stellar handler to Solana, these are the only changes:
- Adds
network. - Renames
stellar_transaction_idtotransaction_id. - Omits
withdraw_anchor_account,withdraw_memo,withdraw_memo_type,deposit_memoanddeposit_memo_type. These are Stellar-specific and have no Solana equivalent.
Every other field keeps the same name and meaning it has on Stellar.
status is not the Ramps API status. The webhook status uses the same vocabulary as the Stellar product (pending_anchor, pending_user_transfer_complete, completed, and related values) on both networks. It is a different set from the Ramps status values in Cash-in status phases and Cash-out status phases. Do not compare the two directly, and do not drive funds movement off the webhook value. Reconcile with GET /status?sync=true.
Verifying the signature
Every notification is signed with Ed25519, on both networks. The signature is over this exact plaintext:
{timestamp}.{your_webhook_host}.{message}{timestamp}is the Unix time in seconds from thetcomponent of theSignatureheader.{your_webhook_host}is the host of the webhook URL you registered, with no scheme, no path, and no port. Ramps registers the host name alone, so a URL such ashttps://api.example.com:4443/webhookis registered asapi.example.com. Including the port would make the plaintext differ from what was signed, and every verification would fail.{message}is the value of themessageproperty in the request body, taken verbatim as a string.
The signature itself is base64 encoded.
What arrives at your endpoint. The delivery is an HTTPS POST with two headers that matter, and the notification arrives as a JSON string nested inside a wrapper object:
Content-Type: application/jsonSignature: t=1787932754,s=<base64 Ed25519 signature>
{ "message": "{\"transaction\":{\"id\":\"bb7f5271-...\",\"network\":\"SOL\", ... }}" }Do not verify against the raw request body. The signature covers the message string alone, not the wrapper. Parse the outer object first, take message exactly as it comes, verify that, and only then parse message into the transaction object. Do not deserialize and re-serialize message before verifying: key order and whitespace would change and the check would fail on a genuine notification.
Split the Signature header on the first = of each component. Base64 padding in s contains = characters, and there is no space after the comma.
Public keys. MoneyGram signs with one key pair per environment. The public half is a base32 G... strkey, the same key format Stellar uses, not a PEM or an X.509 blob.
| Environment | Ed25519 public key |
|---|---|
| Playground (sandbox) | GCUZ6YLL5RQBTYLTTQLPCM73C5XAIUGK2TIMWQH7HPSGWVS2KJ2F3CHS |
| Production | GD5NUMEX7LYHXGXCAD4PGW7JDMOUY2DKRGY5XZHJS5IONVHDKCJYGVCL |
The key is per environment rather than per partner, so if you run separate custodial and non-custodial partner records against the same environment, both verify against the same key.
To decode the strkey yourself: base32-decode it, then drop the leading version byte and the trailing two checksum bytes. The remaining 32 bytes are the raw Ed25519 public key.
Step by step. The same procedure in prose, for other languages:
- Confirm the request carries a
Signatureheader. Reject it if the header is absent. - Split the header on commas, then split each component on its first
=. This yieldst, the Unix time in seconds, ands, the base64 signature. Base64 padding inscontains=characters, so splitting on every=corrupts the value. - Parse the request body as JSON and take the
messageproperty. It is a string holding the notification JSON. Keep it exactly as received. - Build the plaintext by joining, with no separators beyond the periods shown:
tas a string, a., the host of the webhook URL you registered (no scheme, no path, no port), another., then themessagestring verbatim. Do not deserialize and re-serializemessagefirst: key order and whitespace would change and the check would fail on a genuine notification. - Base64-decode
sto get the 64-byte signature. - Decode the
G...strkey for your environment from the table above into a 32-byte Ed25519 public key. - Verify the signature over the plaintext with that key, and reject the notification if verification fails.
- Check freshness, and reject if
tis more than a few minutes in the future or older than your chosen window. Retries replay the original signed payload, so the timestamp does not advance across redeliveries: the window must be at least 60 minutes, the full retry horizon. Allow a little beyond it for delivery latency. See Delivery and retries. - Only now parse
messageinto the transaction object.
const crypto = require('node:crypto') // Decode a G... strkey into an Ed25519 public key. Base32, then drop the// leading version byte and the trailing two checksum bytes; the 32 bytes left// are the raw key. The DER prefix is the fixed SPKI header for Ed25519.const BASE32 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'function ed25519FromStrkey(strkey) { let bits = 0 let value = 0 const bytes = [] for (const char of strkey.replace(/=+$/, '')) { const index = BASE32.indexOf(char) if (index === -1) throw new Error('invalid strkey') value = (value << 5) | index bits += 5 if (bits >= 8) { bytes.push((value >>> (bits - 8)) & 0xff) bits -= 8 } } const decoded = Buffer.from(bytes) const raw = decoded.subarray(1, decoded.length - 2) if (raw.length !== 32) throw new Error('invalid strkey') return crypto.createPublicKey({ key: Buffer.concat([Buffer.from('302a300506032b6570032100', 'hex'), raw]), format: 'der', type: 'spki', })} // MGI_WEBHOOK_PUBLIC_KEY is the G... strkey for your environment, from the table above.const webhookKey = ed25519FromStrkey(MGI_WEBHOOK_PUBLIC_KEY) function handleNotification(req) { const parts = {} for (const piece of String(req.headers.signature).split(',')) { const i = piece.indexOf('=') parts[piece.slice(0, i).trim()] = piece.slice(i + 1).trim() } const message = req.body.message // the notification JSON, still a string const plaintext = `${parts.t}.${WEBHOOK_HOST}.${message}` const verified = crypto.verify( null, Buffer.from(plaintext, 'utf8'), webhookKey, Buffer.from(parts.s, 'base64') ) if (!verified) throw new Error('bad signature') // Retries replay the original signed payload, so the timestamp does not advance. // The retry horizon is 60 minutes, so that is the floor; the extra 5 minutes are // headroom for delivery latency, and MAX_SKEW_MS for clock drift against the signer. const MAX_AGE_MS = 65 * 60 * 1000 const MAX_SKEW_MS = 5 * 60 * 1000 const ageMs = Date.now() - Number(parts.t) * 1000 if (!Number.isFinite(ageMs) || ageMs < -MAX_SKEW_MS || ageMs > MAX_AGE_MS) throw new Error('stale') return JSON.parse(message).transaction}
crypto.verify and the strkey decoder above are all you need: Node's standard library covers Ed25519, and the G... envelope is a base32 encoding rather than a cryptographic difference. If you already depend on @stellar/stellar-sdk, Keypair.fromPublicKey(MGI_WEBHOOK_PUBLIC_KEY).verify(plaintext, signature) does the same work in one call. In any other language, decode the strkey as described above and pass the 32-byte key and 64-byte signature to any Ed25519 library.
Do not use the RSA scheme from the MoneyGram security page. The Signature: t=..., s=... header looks identical, but that page describes MoneyGram's core notification products, which sign with RSA SHA-256 over the raw body. Wallet transaction status events, on Stellar and on every other network, use the Ed25519 scheme and the message wrapper described here.
Delivery and retries
Per the MoneyGram webhooks documentation:
- Delivery is an HTTPS POST to the URL you registered. The URL must use a domain name rather than an IP, listen on port 443, 4443 or 10443, and carry no query parameters.
- Respond
200 OKwithin 10 seconds. Acknowledge first and process asynchronously. Slow downstream work inside the request will trip the timeout and trigger redelivery. - A non-200 response or a timeout is retried up to 10 times within 1 hour using exponential backoff. After that the event is marked undelivered.
- Retries replay the original signed payload, including the original
t. The timestamp does not advance, so a freshness window shorter than the retry horizon will reject legitimate redeliveries. - Retries mean duplicate deliveries are normal. Make your handler idempotent and deduplicate on
idtogether withstatus. - Events can arrive out of order. Order by your own receipt time and reconcile with
GET /status?sync=truerather than trusting the payload timestamps, which are not reliable for ordering today (see the field notes above). - Payloads contain no PII.
| Partner style | Strategy |
|---|---|
| Embedded widget | onTransactionCreated + onComplete for UX; webhook for the finished state, and poll to reconcile |
| Direct API integration | Webhook for the finished state; poll after commit where webhooks are not yet enabled |
| Custodial + ledger | Webhook to the finished state and reconcile by polling, then custodial-specific crediting (cash-in) or debiting reconciliation (cash-out) |
| Document | Topic |
|---|---|
| Custodial integration overview | Shared custodial setup, sessions, profiles, security |
| RAMPS_TRANSACTION_CREATED | Widget creation event |
| Custodial cash-in | Omnibus crediting on completed |
| Partner transaction history | History storage and view mode |