Skip to content

MoonPay Fiat-to-Crypto Integration

MoonPay provides a fiat-to-crypto onramp widget that allows users to purchase cryptocurrency using credit cards, bank transfers, and other traditional payment methods. UberLotto integrates MoonPay as an alternative funding method.

All transactions are stored in the unified payment_transactions table with provider = 'moonpay'. The payment_transaction_events table automatically logs every status change via a database trigger.

Key Files

FilePurpose
app/routes/api.moonpay-sign.tsPOST /api/moonpay-sign — signs widget URLs + pre-creates pending transaction
app/routes/api.moonpay-webhook.tsPOST /api/moonpay-webhook — handles payment notifications with full transaction logging
app/components/MoonPayCheckout.tsxClient component — renders the MoonPay widget

How MoonPay Works

MoonPay provides an embeddable widget (hosted at buy.moonpay.com) that handles the entire fiat-to-crypto purchase flow. The integration requires two server-side components:

  1. URL Signing — MoonPay requires all widget URLs to be cryptographically signed to prevent parameter tampering
  2. Webhook Handling — MoonPay sends transaction status updates via webhooks

Integration Flow

User selects amount in LoadCreditPopup


┌──────────────────────┐
│  Client builds       │  ← Constructs MoonPay widget URL with:
│  MoonPay URL         │    externalCustomerId (user email),
│                      │    externalTransactionId (order number),
│                      │    externalCustomerGid (Shopify GID)
└──────────┬───────────┘


┌──────────────────────┐
│  POST /api/moonpay-  │  ← 1. Auth check (must be logged in)
│  sign { url }        │    2. HMAC-SHA256 sign the query string
│                      │    3. Pre-create pending transaction
│                      │       in payment_transactions
└──────────┬───────────┘


┌──────────────────────┐
│  Append signature    │  ← Client adds &signature={sig}
│  to URL              │    to the MoonPay widget URL
└──────────┬───────────┘


┌──────────────────────┐
│  Open MoonPay widget │  ← User completes purchase on
│  (iframe or redirect)│    MoonPay-hosted page
└──────────────────────┘

           │  (async)

┌──────────────────────┐
│  MoonPay sends       │  ← POST /api/moonpay-webhook
│  webhook             │    with transaction status
└──────────┬───────────┘


┌──────────────────────┐
│  5-layer security    │  ← See "Webhook Security Pipeline"
│  validation          │
└──────────┬───────────┘


┌──────────────────────┐
│  Find-or-create      │  ← 3-step lookup: provider_txn_id →
│  transaction record  │    order_number → create new (fallback)
└──────────┬───────────┘


┌──────────────────────┐
│  UPDATE transaction  │  ← Status, tx_hash, wallet_address,
│  in DB               │    crypto details; event auto-logged
└──────────────────────┘

Widget URL Parameters

The client builds the MoonPay widget URL with these custom parameters that link the MoonPay purchase back to the UberLotto user:

ParameterValuePurpose
externalCustomerIdUser's email addressIdentifies the customer in webhook payloads
externalTransactionIdGenerated order number (e.g. MP-1699892345678-A3B4C5)Links MoonPay transaction to the pre-created payment_transactions row
externalCustomerGidShopify Customer GID (e.g. gid://shopify/Customer/123)Stored as user_id in the transaction record
baseCurrencyAmountUSD amountPurchase amount
walletAddressDestination crypto walletWhere the crypto will be sent

URL Signing Endpoint

POST /api/moonpay-sign

This endpoint performs two actions: signs the MoonPay URL and pre-creates a pending transaction record.

Security Layers

  1. Authentication — requires logged-in user (context.customerAccount.isLoggedIn())
  2. Rate Limiting — IP-based rate limiting via the shared rate limiter
  3. URL Validation — only signs URLs pointing to allowed MoonPay hosts
  4. CORS — origin-restricted (localhost in dev, same-origin in production)

Allowed Hosts

The endpoint only signs URLs targeting these MoonPay domains:

typescript
const allowedHosts = [
  'buy.moonpay.com',
  'buy-sandbox.moonpay.com',
  'sell.moonpay.com',
  'sell-sandbox.moonpay.com',
];

Any URL with a different hostname is rejected with a 400 error.

Signing Algorithm

MoonPay uses HMAC-SHA256 to sign the query string (including the leading ?):

typescript
// Extract query string including '?' prefix
const queryString = parsedUrl.search;

// HMAC-SHA256 using Web Crypto API
const cryptoKey = await crypto.subtle.importKey(
  'raw',
  encoder.encode(secretKey),
  { name: 'HMAC', hash: 'SHA-256' },
  false,
  ['sign'],
);

const signatureBuffer = await crypto.subtle.sign('HMAC', cryptoKey, encoder.encode(queryString));

// Base64 encode the signature
const signature = btoa(String.fromCharCode(...new Uint8Array(signatureBuffer)));

Pending Transaction Pre-Creation

After signing, the endpoint extracts parameters from the MoonPay URL and creates a pending payment_transactions row:

FieldSource
user_emailexternalCustomerId param (user's email)
order_numberexternalTransactionId param
user_idexternalCustomerGid param (Shopify GID)
amount / amount_usdbaseCurrencyAmount param
currencybaseCurrencyCode param (defaults to USD)
wallet_addresswalletAddress param
provider'moonpay'
status'pending'
metadata{ source: 'moonpay-sign' }

This pre-creation is non-blocking — if it fails, the URL is still signed and the user can proceed. The webhook handler has fallback logic to create a transaction record if no pre-created one is found.

Request / Response

typescript
// Request
POST /api/moonpay-sign
{ "url": "https://buy.moonpay.com?apiKey=pk_test_...&currencyCode=eth&..." }

// Response
{ "success": true, "signature": "base64EncodedSignature==" }

Webhook Endpoint

POST /api/moonpay-webhook

Webhook Security Pipeline

When MoonPay sends a payment notification, the request passes through a 5-layer security pipeline:

Webhook Request (POST)

    ┌────┴────┐
    │ Layer 1 │  IP Rate Limiting
    └────┬────┘  Per-IP rate limit check

    ┌────┴────┐
    │ Layer 2 │  Global Rate Limiting
    └────┬────┘  Circuit breaker for infrastructure protection

    ┌────┴────┐
    │ Layer 3 │  HMAC-SHA256 Signature Verification
    └────┬────┘  Parses Moonpay-Signature-V2 header,
         │       verifies timestamp.body, hex-encoded

    ┌────┴────┐
    │ Layer 4 │  Replay Attack Prevention
    └────┬────┘  Nonce-based via checkGenericWebhookNonce()
         │       using webhook_nonces table

    ┌────┴────┐
    │ Layer 5 │  Security Event Logging
    └─────────┘  All events logged to security_events table

Signature Verification

Correction from Previous Docs

The previous documentation showed the webhook signature as base64-encoded. In fact, MoonPay's Moonpay-Signature-V2 header uses hex encoding, NOT base64. The header format is t=TIMESTAMP,s=SIGNATURE where the signature is computed over TIMESTAMP.BODY.

MoonPay sends a signature in the Moonpay-Signature-V2 HTTP header with the format: t=TIMESTAMP,s=SIGNATURE. The verification process:

typescript
// Step 1: Parse header — format is "t=TIMESTAMP,s=SIGNATURE"
const parts = signatureHeader.split(',');
const timestamp = timestampPart.slice(2); // Remove "t="
const receivedSignature = signaturePart.slice(2); // Remove "s="

// Step 2: Timestamp freshness check (5-minute window)
const MAX_AGE_SECONDS = 300;
if (Math.abs(nowSeconds - timestampSeconds) > MAX_AGE_SECONDS) {
  return false; // Reject stale or future webhooks
}

// Step 3: Create signed payload: "timestamp.body"
const signedPayload = `${timestamp}.${body}`;

// Step 4: Compute HMAC-SHA256 using MOONPAY_WEBHOOK_KEY
const signatureBuffer = await crypto.subtle.sign('HMAC', cryptoKey, payloadData);

// Step 5: Convert to hex (NOT base64)
const computedSignature = Array.from(new Uint8Array(signatureBuffer))
  .map(b => b.toString(16).padStart(2, '0'))
  .join('');

// Step 6: Constant-time comparison (XOR-based)
return timingSafeEqual(computedSignature, receivedSignature);

Constant-Time Comparison

The signature comparison uses a custom XOR-based timingSafeEqual() function, NOT crypto.timingSafeEqual. This is because the Web Crypto API (used in Cloudflare Workers / Oxygen) does not expose Node.js crypto.timingSafeEqual.

Replay Protection

The replay protection uses checkGenericWebhookNonce() with the MoonPay transaction ID, status, and event type:

typescript
const nonceCheck = await checkGenericWebhookNonce(
  {
    rawNonceString: `${payload.data.id}:${payload.data.status}:${payload.type}`,
    txn_id: payload.data.id,
    provider: 'moonpay',
  },
  securityContext
);

This stores a SHA-256 hash nonce in the webhook_nonces table to prevent duplicate processing of the same webhook event.

Payload Parsing

JSON String Data

MoonPay may send the data field as a JSON string rather than an object. The webhook handler detects this and parses it automatically:

typescript
if (typeof parsed.data === 'string') {
  parsed.data = JSON.parse(parsed.data);
}

Webhook Event Types

Event TypeDescription
transaction_createdNew transaction initiated
transaction_updatedTransaction status changed
transaction_failedTransaction failed

Status Mapping

MoonPay StatusInternal PaymentStatusDescription
completedcompletedPayment successful
failedfailedPayment failed
pendingpendingAwaiting payment
waitingPaymentprocessingAwaiting user payment
waitingAuthorizationprocessingAwaiting bank authorization

Webhook Payload Structure

typescript
interface MoonPayWebhookPayload {
  type: 'transaction_created' | 'transaction_updated' | 'transaction_failed';
  data: {
    id: string;
    status: MoonPayTransactionStatus;
    baseCurrencyAmount: number;
    quoteCurrencyAmount: number;
    walletAddress: string;
    externalTransactionId?: string;   // Our order number
    externalCustomerId?: string;       // User's email
    baseCurrency?: { code: string; name: string };
    quoteCurrency?: { code: string; name: string };
    createdAt?: string;
    updatedAt?: string;
    cryptoTransactionId?: string;      // Blockchain tx hash
    failureReason?: string;
  };
}

Response Codes

The webhook handler returns 200 OK for all processed webhooks (even errors) to prevent MoonPay from retrying. This is per MoonPay's best practice documentation.

Transaction Lookup Strategy

The webhook uses a 3-step lookup strategy to find or create the transaction record:

MoonPay webhook received


┌──────────────────────────┐
│  Step 1: Look up by      │  ← getTransactionByProviderTxnId(moonpayTxnId)
│  provider_txn_id         │    Matches previously updated records
└──────────┬───────────────┘
           │ not found

┌──────────────────────────┐
│  Step 2: Look up by      │  ← getTransactionByOrderNumber(externalTransactionId)
│  order_number            │    Matches pre-created pending records
└──────────┬───────────────┘
           │ not found

┌──────────────────────────┐
│  Step 3: Create new      │  ← createPaymentTransaction()
│  transaction (fallback)  │    Edge case: no pre-created record
└──────────────────────────┘

What Gets Stored

When updating an existing transaction:

FieldSource
statusMapped from MoonPay status
provider_txn_idMoonPay's internal transaction ID
tx_hashdata.cryptoTransactionId (blockchain hash)
wallet_addressdata.walletAddress
crypto_currencydata.quoteCurrency.code
amount_usddata.baseCurrencyAmount (if USD)
completed_atSet when status is completed
error_code / error_messageSet when status is failed
metadataMerged with webhook event details

When creating a new fallback transaction:

FieldSource
user_emailexternalCustomerId (validated as email, not Shopify GID)
order_numberexternalTransactionId or MP-{moonpayTxnId}
provider'moonpay'
All other fieldsSame as update mapping above

Email Validation

The webhook handler validates that externalCustomerId is a real email address (contains @, does not start with gid://). If it's a Shopify GID instead of an email, it falls back to [email protected] to prevent invalid data in the user_email column.

Environment Variables

VariableRequiredDescription
MOONPAY_PUBLISHABLE_KEYYesPublic API key for the MoonPay widget (client-side)
MOONPAY_SECRET_KEYYesSecret key for signing widget URLs (HMAC-SHA256, server-only)
MOONPAY_WEBHOOK_KEYYesWebhook key for verifying incoming webhook signatures (server-only)
MOONPAY_WALLET_ADDRESSYesDestination cryptocurrency wallet address
MOONPAY_ENVIRONMENTYessandbox or production — controls which MoonPay hosts are used

Security

MOONPAY_SECRET_KEY and MOONPAY_WEBHOOK_KEY are server-only secrets and must never be exposed to the client. MOONPAY_PUBLISHABLE_KEY is safe for client-side use (passed to the widget). The signing endpoint requires user authentication to prevent unauthorized URL generation.

Data Flow Summary

StepWhereWhat Happens
1ClientUser selects amount in LoadCreditPopup
2ClientWidget URL built with externalCustomerId (email), externalTransactionId (order number), externalCustomerGid (Shopify GID)
3api.moonpay-signAuth check → rate limit → URL validation → HMAC-SHA256 sign → pre-create pending payment_transactions row
4ClientAppends &signature={sig} to URL, opens MoonPay widget
5MoonPayUser completes purchase on MoonPay-hosted page
6api.moonpay-webhookRate limit → signature verification (timestamp.body, hex) → timestamp freshness (5-min) → replay protection → payload parsing
7api.moonpay-webhook3-step transaction lookup → UPDATE existing or CREATE new row → event auto-logged

UberLotto Technical Documentation