Reference

Transaction status & webhooks

Widget events, status polling, and MoneyGram status webhooks for cash-in and cash-out (custodial and non-custodial).

Status webhooksStatus pollingWidget events

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.


Summary

MechanismBest forApplies to
Widget events (RAMPS_TRANSACTION_*)Embedded Web / RN appsWidget integrations
Status polling (GET /status?sync=true)Server-side apps, backup, reconciliationAll flows
MoneyGram status webhooksServer-to-server pushAll flows (same partner URL)
AreaStatus
Widget RAMPS_TRANSACTION_CREATED (includes mgiTransactionId)Implemented
Status polling APIImplemented
Partner webhook URL configuration (partner portal)Implemented
Webhook payload, Stellar (XLM)Live, unchanged
Webhook payload, Solana (SOL)Live. Documented below

TEXT
                    ┌─────────────────────────────────────┐                    │      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.


1. Widget events

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.

EventCash-inCash-out
RAMPS_TRANSACTION_CREATEDAfter validate succeedsAfter validate succeeds
RAMPS_TRANSACTION_COMPLETEDone screen (confirmation code)Done screen (reference number)
RAMPS_TRANSACTION_FAILEDError pathError path
RAMPS_SIGN_TRANSACTIONNot usedNon-custodial only
RAMPS_DEPOSIT_ADDRESSNot usedCustodial 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.


2. Status polling

HTTP
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.

FlowStartStop when
Cash-inAfter commitcompleted, failed, or quote_expired
Cash-out (non-custodial)After check-deposit succeedscompleted 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 confirmedSame 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

PhaseTypical status
After validatevalidated / pending_kyc
After commitcommitted
User paid at counterfunds_received
USDC deliveredcompleted
Window expiredquote_expired

Cash-out status phases

PhaseTypical statusNotes
Awaiting consumer commitpending_commitCommit still in flight
Awaiting USDCawaiting_fundsSign & Send window open, or deposit not yet confirmed at MoneyGram
Deposit submitted, confirmation pendingawaiting_fundsCustomer 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 receivedfunds_receivedOn-chain deposit confirmed
Ready for pickupcompleted (cash-out)Pickup code active
Picked uppaid_outCash collected at agent
Refund lifecyclerefund_requested, refund_mgi_pending, refund_mgi_success, refunded, refund_failedCash-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

EnvironmentWhen the webhook URL field is available
SandboxAfter sandbox onboarding is complete
ProductionAfter 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: mgiTransactionId from 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.

TEXT
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.

NetworkPayload
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:

NetworkAlways 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:

JSON
{  "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:

JSON
{  "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

FieldNotes
idMoneyGram transaction id. This is the mgiTransactionId you stored at transaction creation, and it is your correlation key.
networkNetwork code, SOL for Solana. Present on Solana notifications only, so it is the reliable way to tell the two shapes apart.
transaction_idOn-chain transaction hash or signature. Absent until a settling transfer exists on chain, so expect it to be missing on early events.
external_transaction_idMoneyGram reference number, or the confirmation number when no reference number is set.
kinddeposit for cash-in, withdrawal for cash-out.
amount_in, amount_in_assetAmount and asset the customer sends.
amount_out, amount_out_assetAmount and asset the customer receives.
amount_fee, amount_fee_assetFee and the asset the fee is denominated in.
fromCustomer account for a cash-out.
toMoneyGram receiving account as recorded on the transaction. Do not use it to infer the network, use network.
started_at, updated_at, completed_atTimestamps 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.
statusTransaction 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_id to transaction_id.
  • Omits withdraw_anchor_account, withdraw_memo, withdraw_memo_type, deposit_memo and deposit_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:

TEXT
{timestamp}.{your_webhook_host}.{message}
  • {timestamp} is the Unix time in seconds from the t component of the Signature header.
  • {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 as https://api.example.com:4443/webhook is registered as api.example.com. Including the port would make the plaintext differ from what was signed, and every verification would fail.
  • {message} is the value of the message property 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:

TEXT
Content-Type: application/jsonSignature: t=1787932754,s=<base64 Ed25519 signature>
JSON
{ "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.

EnvironmentEd25519 public key
Playground (sandbox)GCUZ6YLL5RQBTYLTTQLPCM73C5XAIUGK2TIMWQH7HPSGWVS2KJ2F3CHS
ProductionGD5NUMEX7LYHXGXCAD4PGW7JDMOUY2DKRGY5XZHJS5IONVHDKCJYGVCL

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:

  1. Confirm the request carries a Signature header. Reject it if the header is absent.
  2. Split the header on commas, then split each component on its first =. This yields t, the Unix time in seconds, and s, the base64 signature. Base64 padding in s contains = characters, so splitting on every = corrupts the value.
  3. Parse the request body as JSON and take the message property. It is a string holding the notification JSON. Keep it exactly as received.
  4. Build the plaintext by joining, with no separators beyond the periods shown: t as a string, a ., the host of the webhook URL you registered (no scheme, no path, no port), another ., then the message string verbatim. Do not deserialize and re-serialize message first: key order and whitespace would change and the check would fail on a genuine notification.
  5. Base64-decode s to get the 64-byte signature.
  6. Decode the G... strkey for your environment from the table above into a 32-byte Ed25519 public key.
  7. Verify the signature over the plaintext with that key, and reject the notification if verification fails.
  8. Check freshness, and reject if t is 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.
  9. Only now parse message into the transaction object.
JavaScript
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 OK within 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 id together with status.
  • Events can arrive out of order. Order by your own receipt time and reconcile with GET /status?sync=true rather than trusting the payload timestamps, which are not reliable for ordering today (see the field notes above).
  • Payloads contain no PII.

Partner styleStrategy
Embedded widgetonTransactionCreated + onComplete for UX; webhook for the finished state, and poll to reconcile
Direct API integrationWebhook for the finished state; poll after commit where webhooks are not yet enabled
Custodial + ledgerWebhook to the finished state and reconcile by polling, then custodial-specific crediting (cash-in) or debiting reconciliation (cash-out)

DocumentTopic
Custodial integration overviewShared custodial setup, sessions, profiles, security
RAMPS_TRANSACTION_CREATEDWidget creation event
Custodial cash-inOmnibus crediting on completed
Partner transaction historyHistory storage and view mode