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
| File | Purpose |
|---|---|
app/routes/api.moonpay-sign.ts | POST /api/moonpay-sign — signs widget URLs + pre-creates pending transaction |
app/routes/api.moonpay-webhook.ts | POST /api/moonpay-webhook — handles payment notifications with full transaction logging |
app/components/MoonPayCheckout.tsx | Client 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:
- URL Signing — MoonPay requires all widget URLs to be cryptographically signed to prevent parameter tampering
- 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:
| Parameter | Value | Purpose |
|---|---|---|
externalCustomerId | User's email address | Identifies the customer in webhook payloads |
externalTransactionId | Generated order number (e.g. MP-1699892345678-A3B4C5) | Links MoonPay transaction to the pre-created payment_transactions row |
externalCustomerGid | Shopify Customer GID (e.g. gid://shopify/Customer/123) | Stored as user_id in the transaction record |
baseCurrencyAmount | USD amount | Purchase amount |
walletAddress | Destination crypto wallet | Where 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
- Authentication — requires logged-in user (
context.customerAccount.isLoggedIn()) - Rate Limiting — IP-based rate limiting via the shared rate limiter
- URL Validation — only signs URLs pointing to allowed MoonPay hosts
- CORS — origin-restricted (localhost in dev, same-origin in production)
Allowed Hosts
The endpoint only signs URLs targeting these MoonPay domains:
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 ?):
// 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:
| Field | Source |
|---|---|
user_email | externalCustomerId param (user's email) |
order_number | externalTransactionId param |
user_id | externalCustomerGid param (Shopify GID) |
amount / amount_usd | baseCurrencyAmount param |
currency | baseCurrencyCode param (defaults to USD) |
wallet_address | walletAddress 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
// Request
POST /api/moonpay-sign
{ "url": "https://buy.moonpay.com?apiKey=pk_test_...¤cyCode=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 tableSignature 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:
// 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:
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:
if (typeof parsed.data === 'string') {
parsed.data = JSON.parse(parsed.data);
}Webhook Event Types
| Event Type | Description |
|---|---|
transaction_created | New transaction initiated |
transaction_updated | Transaction status changed |
transaction_failed | Transaction failed |
Status Mapping
| MoonPay Status | Internal PaymentStatus | Description |
|---|---|---|
completed | completed | Payment successful |
failed | failed | Payment failed |
pending | pending | Awaiting payment |
waitingPayment | processing | Awaiting user payment |
waitingAuthorization | processing | Awaiting bank authorization |
Webhook Payload Structure
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:
| Field | Source |
|---|---|
status | Mapped from MoonPay status |
provider_txn_id | MoonPay's internal transaction ID |
tx_hash | data.cryptoTransactionId (blockchain hash) |
wallet_address | data.walletAddress |
crypto_currency | data.quoteCurrency.code |
amount_usd | data.baseCurrencyAmount (if USD) |
completed_at | Set when status is completed |
error_code / error_message | Set when status is failed |
metadata | Merged with webhook event details |
When creating a new fallback transaction:
| Field | Source |
|---|---|
user_email | externalCustomerId (validated as email, not Shopify GID) |
order_number | externalTransactionId or MP-{moonpayTxnId} |
provider | 'moonpay' |
| All other fields | Same 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
| Variable | Required | Description |
|---|---|---|
MOONPAY_PUBLISHABLE_KEY | Yes | Public API key for the MoonPay widget (client-side) |
MOONPAY_SECRET_KEY | Yes | Secret key for signing widget URLs (HMAC-SHA256, server-only) |
MOONPAY_WEBHOOK_KEY | Yes | Webhook key for verifying incoming webhook signatures (server-only) |
MOONPAY_WALLET_ADDRESS | Yes | Destination cryptocurrency wallet address |
MOONPAY_ENVIRONMENT | Yes | sandbox 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
| Step | Where | What Happens |
|---|---|---|
| 1 | Client | User selects amount in LoadCreditPopup |
| 2 | Client | Widget URL built with externalCustomerId (email), externalTransactionId (order number), externalCustomerGid (Shopify GID) |
| 3 | api.moonpay-sign | Auth check → rate limit → URL validation → HMAC-SHA256 sign → pre-create pending payment_transactions row |
| 4 | Client | Appends &signature={sig} to URL, opens MoonPay widget |
| 5 | MoonPay | User completes purchase on MoonPay-hosted page |
| 6 | api.moonpay-webhook | Rate limit → signature verification (timestamp.body, hex) → timestamp freshness (5-min) → replay protection → payload parsing |
| 7 | api.moonpay-webhook | 3-step transaction lookup → UPDATE existing or CREATE new row → event auto-logged |