Overview
The Web SDK consists of the following files:| File | Description |
|---|---|
types.ts | Type definitions imported from generated OpenAPI types, plus SDK-specific types like Result, Connection, and error types |
api.ts | API client functions for fetching payment methods and quotes |
client.ts | Main createClient function that manages credentials and provides a unified interface |
crypto.ts | X25519 key generation and AES-GCM decryption for secure credential handling |
frames.ts | Iframe management for check, connect, and Apple Pay frames with postMessage communication |
index.ts | Public API exports |
Project Setup
package.json
package.json
package.json
{
"name": "@moonpay/platform",
"version": "0.0.1",
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"scripts": {
"generate": "openapi-ts",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@noble/ciphers": "^2.1.1",
"@noble/curves": "^2.0.1",
"@noble/hashes": "^2.0.1"
},
"devDependencies": {
"@hey-api/openapi-ts": "^0.92.3",
"typescript": "~5.9.3"
}
}
openapi-ts.config.ts - Type generation config
openapi-ts.config.ts - Type generation config
Configuration for generating TypeScript types from the OpenAPI spec. Download the
openapi.json file to your project root, then run npm run generate.openapi-ts.config.ts
import { defineConfig } from "@hey-api/openapi-ts";
export default defineConfig({
input: "./openapi.json",
output: {
clean: true,
lint: false,
path: "./src/gen",
},
plugins: [
{
name: "@hey-api/typescript",
enums: false, // Use union types instead of enums for better tree-shaking
},
],
});
Source Files
index.ts - Public API exports
index.ts - Public API exports
index.ts
// Client
export { createClient } from "./client.js";
export type { Client, CreateClientOptions } from "./client.js";
// Types
export type {
Result,
Connection,
Credentials,
CreateClientError,
GetConnectionError,
ConnectError,
ConnectEventError,
GetPaymentMethodsError,
GetQuoteError,
SetupApplePayError,
ConnectEvent,
ApplePayEvent,
ApplePayEventError,
ConnectFrame,
ApplePayFrame,
ConnectOptions,
GetQuoteParams,
PaymentMethod,
Quote,
Transaction,
TransactionStatus,
Fees,
QuoteLimits,
Asset,
Wallet,
} from "./types.js";
types.ts - Type definitions
types.ts - Type definitions
Types are imported from the generated OpenAPI types where possible, with SDK-specific types defined separately.
types.ts
// Import generated types from OpenAPI spec
import type {
AuthorizeReturnType,
GetQuoteResponse,
MoonPayPaymentsPaymentMethod,
TransactionStatus as GeneratedTransactionStatus,
Fees as GeneratedFees,
QuoteLimits as GeneratedQuoteLimits,
Asset as GeneratedAsset,
Wallet as GeneratedWallet,
} from "./gen";
// Re-export generated types
export type {
GetQuoteResponse,
Fees,
QuoteLimits,
Asset,
Wallet,
TransactionStatus,
} from "./gen";
// Alias GetQuoteResponse as Quote for convenience
export type Quote = GetQuoteResponse;
// Result type for SDK operations
export type Result<T, E> = { ok: true; value: T } | { ok: false; error: E };
// Credentials derived from AuthorizeReturnType
export type Credentials = Pick<
AuthorizeReturnType,
"accessToken" | "clientToken"
>;
// Connection status
export type Connection =
| { status: "connectionRequired" }
| { status: "active"; credentials: Credentials }
| { status: "unavailable" }
| { status: "pending" }
| { status: "failed"; reason: string };
// Error types (SDK-specific)
export type CreateClientError = {
kind: "validationError" | "genericError";
message: string;
};
export type GetConnectionError = {
kind:
| "ipAddressMismatch"
| "invalidSessionToken"
| "frameTimeout"
| "genericError";
message: string;
};
export type ConnectError = {
kind:
| "ipAddressMismatch"
| "invalidSessionToken"
| "frameTimeout"
| "genericError";
message: string;
};
export type ConnectEventError = {
kind: "genericError";
message: string;
};
export type GetPaymentMethodsError = {
kind: "validationError" | "genericError";
message: string;
};
export type GetQuoteError = {
kind: "validationError" | "genericError";
message: string;
};
export type SetupApplePayError = {
kind:
| "invalidQuote"
| "quoteExpired"
| "applePayUnavailable"
| "genericError";
message: string;
};
// Event types (SDK-specific)
export type ConnectEvent =
| { kind: "ready" }
| {
kind: "complete";
payload: { frame: { dispose: () => void } };
connection: Connection;
}
| { kind: "error"; payload: ConnectEventError };
// Frame types (SDK-specific)
export type ConnectFrame = {
dispose: () => void;
};
// Connect options (SDK-specific)
export type ConnectOptions = {
container: HTMLElement;
theme?: { appearance: "dark" | "light" };
onEvent: (event: ConnectEvent) => void;
};
// Payment method - re-export from generated types
export type PaymentMethod = MoonPayPaymentsPaymentMethod;
// Quote params (SDK-specific for convenience)
export type GetQuoteParams = {
source: string;
destination: string;
sourceAmount: string;
walletAddress: string;
paymentMethod: string;
};
// Transaction - uses generated TransactionStatus with discriminated union
export type TransactionStatus = GeneratedTransactionStatus;
export type Transaction =
| { id: string; status: Exclude<TransactionStatus, "failed"> }
| { id: string; status: "failed"; failureReason: string };
// Apple Pay types (SDK-specific)
export type ApplePayEventError = {
kind:
| "invalidQuote"
| "quoteExpired"
| "applePayUnavailable"
| "genericError";
message: string;
};
export type ApplePayEvent =
| { kind: "ready" }
| { kind: "complete"; payload: { transaction: Transaction } }
| {
kind: "quoteExpired";
payload: { setQuote: (quoteSignature: string) => void };
}
| { kind: "error"; payload: ApplePayEventError }
| { kind: "unsupported" };
export type ApplePayFrame = {
setQuote: (quoteSignature: string) => void;
dispose: () => void;
};
export type SetupApplePayOptions = {
quote: string;
container: HTMLElement;
onEvent: (event: ApplePayEvent) => void;
};
client.ts - Main client
client.ts - Main client
client.ts
import {
runCheckFrame,
setupConnectFrame,
setupApplePay as setupApplePayFrame,
type KeyPair,
} from "./frames.js";
import {
getPaymentMethods as getPaymentMethodsApi,
getQuote as getQuoteApi,
} from "./api.js";
import type {
Result,
Connection,
Credentials,
CreateClientError,
GetConnectionError,
ConnectError,
ConnectOptions,
ConnectFrame,
PaymentMethod,
GetPaymentMethodsError,
Quote,
GetQuoteParams,
GetQuoteError,
ApplePayFrame,
ApplePayEvent,
SetupApplePayError,
} from "./types.js";
export type MessageDirection = "inbound" | "outbound";
export type SetupApplePayClientOptions = {
quote: string;
container: HTMLElement;
onEvent: (event: ApplePayEvent) => void;
};
export type Client = {
getConnection: () => Promise<Result<Connection, GetConnectionError>>;
connect: (
options: ConnectOptions,
) => Promise<Result<ConnectFrame, ConnectError>>;
getPaymentMethods: () => Promise<
Result<PaymentMethod[], GetPaymentMethodsError>
>;
getQuote: (params: GetQuoteParams) => Promise<Result<Quote, GetQuoteError>>;
setupApplePay: (
options: SetupApplePayClientOptions,
) => Promise<Result<ApplePayFrame, SetupApplePayError>>;
};
export type CreateClientOptions = {
sessionToken: string;
onMessage?: (direction: MessageDirection, data: unknown) => void;
};
export function createClient(
options: CreateClientOptions,
): Result<Client, CreateClientError> {
const { sessionToken, onMessage } = options;
if (!sessionToken || typeof sessionToken !== "string") {
return {
ok: false,
error: { kind: "validationError", message: "Invalid sessionToken" },
};
}
let storedKeyPair: KeyPair | null = null;
let storedCredentials: Credentials | null = null;
const client: Client = {
async getConnection() {
const result = await runCheckFrame({ sessionToken, onMessage });
if (result.ok) {
storedKeyPair = result.keyPair;
if (result.value.status === "active")
storedCredentials = result.value.credentials;
return { ok: true, value: result.value };
}
return {
ok: false,
error: {
kind: result.error.kind as GetConnectionError["kind"],
message: result.error.message,
},
};
},
async connect(connectOptions) {
const { container, theme, onEvent } = connectOptions;
if (!storedKeyPair) {
const checkResult = await runCheckFrame({ sessionToken, onMessage });
if (!checkResult.ok) {
return {
ok: false,
error: {
kind: checkResult.error.kind as ConnectError["kind"],
message: checkResult.error.message,
},
};
}
storedKeyPair = checkResult.keyPair;
}
const wrappedOnEvent = (event: Parameters<typeof onEvent>[0]) => {
if (
event.kind === "complete" &&
event.connection?.status === "active"
) {
storedCredentials = event.connection.credentials;
}
onEvent(event);
};
const result = await setupConnectFrame({
sessionToken,
keyPair: storedKeyPair,
container,
theme: theme?.appearance,
onEvent: wrappedOnEvent,
onMessage,
});
return result.ok
? { ok: true, value: result.value }
: {
ok: false,
error: {
kind: result.error.kind as ConnectError["kind"],
message: result.error.message,
},
};
},
async getPaymentMethods() {
if (!storedCredentials) {
return {
ok: false,
error: {
kind: "validationError",
message:
"No active connection. Call getConnection() or connect() first.",
},
};
}
return getPaymentMethodsApi({
accessToken: storedCredentials.accessToken,
});
},
async getQuote(params) {
if (!storedCredentials) {
return {
ok: false,
error: {
kind: "validationError",
message:
"No active connection. Call getConnection() or connect() first.",
},
};
}
return getQuoteApi({
accessToken: storedCredentials.accessToken,
...params,
});
},
async setupApplePay(applePayOptions) {
if (!storedCredentials) {
return {
ok: false,
error: {
kind: "genericError",
message:
"No active connection. Call getConnection() or connect() first.",
},
};
}
return setupApplePayFrame({
clientToken: storedCredentials.clientToken,
quote: applePayOptions.quote,
container: applePayOptions.container,
onEvent: applePayOptions.onEvent,
});
},
};
return { ok: true, value: client };
}
api.ts - API client functions
api.ts - API client functions
api.ts
import type {
Result,
PaymentMethod,
GetPaymentMethodsError,
Quote,
GetQuoteParams,
GetQuoteError,
} from "./types.js";
const MOONPAY_API_URL = "https://api.moonpay.com";
export type GetPaymentMethodsOptions = { accessToken: string };
export type GetQuoteOptions = GetQuoteParams & { accessToken: string };
export async function getPaymentMethods(
options: GetPaymentMethodsOptions,
): Promise<Result<PaymentMethod[], GetPaymentMethodsError>> {
try {
const response = await fetch(
`${MOONPAY_API_URL}/platform/v1/payment-methods`,
{
method: "GET",
headers: {
Authorization: `Bearer ${options.accessToken}`,
"Content-Type": "application/json",
},
},
);
const data = await response.json();
if (response.ok && Array.isArray(data)) return { ok: true, value: data };
return {
ok: false,
error: {
kind: "genericError",
message: data?.message || "Failed to fetch payment methods",
},
};
} catch (err) {
return {
ok: false,
error: {
kind: "genericError",
message: err instanceof Error ? err.message : "Request failed",
},
};
}
}
export async function getQuote(
options: GetQuoteOptions,
): Promise<Result<Quote, GetQuoteError>> {
const {
accessToken,
source,
destination,
sourceAmount,
walletAddress,
paymentMethod,
} = options;
try {
const response = await fetch(`${MOONPAY_API_URL}/platform/v1/quotes/buy`, {
method: "POST",
headers: {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
source: { asset: { code: source }, amount: sourceAmount },
destination: { asset: { code: destination } },
wallet: { address: walletAddress },
paymentMethod: { type: paymentMethod },
}),
});
const data = await response.json();
if (response.ok && data.data) {
const quote: Quote = data.data;
if (!quote.signature) {
return {
ok: false,
error: {
kind: "validationError",
message: "Quote is not executable - signature missing",
},
};
}
return { ok: true, value: quote };
}
return {
ok: false,
error: {
kind: "genericError",
message: data?.message || "Failed to get quote",
},
};
} catch (err) {
return {
ok: false,
error: {
kind: "genericError",
message: err instanceof Error ? err.message : "Request failed",
},
};
}
}
crypto.ts - Encryption utilities
crypto.ts - Encryption utilities
crypto.ts
import { gcm } from "@noble/ciphers/aes.js";
import { x25519 } from "@noble/curves/ed25519.js";
import { hkdf } from "@noble/hashes/hkdf.js";
import { sha256 } from "@noble/hashes/sha2.js";
export type KeyPair = { privateKey: string; publicKey: string };
const hexToBytes = (hex: string): Uint8Array => {
const bytes = new Uint8Array(hex.length / 2);
for (let i = 0; i < bytes.length; i++)
bytes[i] = parseInt(hex.substr(i * 2, 2), 16);
return bytes;
};
const bytesToHex = (bytes: Uint8Array): string =>
bytes.reduce((hex, b) => hex + b.toString(16).padStart(2, "0"), "");
export const decrypt = (
encryptedData: string,
privateKeyHex: string,
): string => {
const parsed = JSON.parse(atob(encryptedData)) as {
ephemeralPublicKey: string;
iv: string;
ciphertext: string;
};
const sharedSecret = x25519.getSharedSecret(
hexToBytes(privateKeyHex),
hexToBytes(parsed.ephemeralPublicKey),
);
const encryptionKey = hkdf(sha256, sharedSecret, undefined, undefined, 32);
const plainTextBytes = gcm(encryptionKey, hexToBytes(parsed.iv)).decrypt(
hexToBytes(parsed.ciphertext),
);
return new TextDecoder().decode(plainTextBytes);
};
export const generateKeyPair = (): KeyPair => {
const { secretKey, publicKey } = x25519.keygen();
return {
privateKey: bytesToHex(secretKey),
publicKey: bytesToHex(publicKey),
};
};
frames.ts - Frame handling
frames.ts - Frame handling
Iframe management for check, connect, and Apple Pay frames.
frames.ts
import { decrypt, generateKeyPair, type KeyPair } from "./crypto.js";
import type {
Connection,
Credentials,
Result,
ApplePayFrame,
ApplePayEvent,
ApplePayEventError,
Transaction,
SetupApplePayError,
SetupApplePayOptions,
ConnectEvent,
} from "./types.js";
const FRAME_ORIGIN = "https://platform.moonpay.com";
type FrameMessage = {
version: 2;
meta: { channelId: string };
kind: string;
payload?: unknown;
};
type CompletePayload = {
status: Connection["status"];
credentials?: string;
reason?: string;
};
type ErrorPayload = { code: string; message: string };
type MessageDirection = "inbound" | "outbound";
function parseMessage(data: unknown, channelId: string): FrameMessage | null {
try {
const message: FrameMessage =
typeof data === "string" ? JSON.parse(data) : data;
return message.version === 2 && message.meta?.channelId === channelId
? message
: null;
} catch {
return null;
}
}
function decryptCredentials(
encrypted: string,
privateKey: string,
): Credentials {
return JSON.parse(decrypt(encrypted, privateKey)) as Credentials;
}
function buildFrameUrl(path: string, params: Record<string, string>): string {
const url = new URL(path, FRAME_ORIGIN);
Object.entries(params).forEach(([k, v]) => url.searchParams.set(k, v));
return url.toString();
}
const FRAME_TIMEOUT_MS = 10000;
const generateChannelId = () => `ch_${crypto.randomUUID()}`;
// Check Frame
export type RunCheckFrameResult =
| { ok: true; value: Connection; keyPair: KeyPair }
| { ok: false; error: { kind: string; message: string } };
export function runCheckFrame(options: {
sessionToken: string;
onMessage?: (dir: MessageDirection, data: unknown) => void;
}): Promise<RunCheckFrameResult> {
return new Promise((resolve) => {
const channelId = generateChannelId();
const keyPair = generateKeyPair();
let timeoutId: ReturnType<typeof setTimeout>;
const iframe = document.createElement("iframe");
iframe.style.display = "none";
iframe.src = buildFrameUrl("/platform/v1/check-connection", {
sessionToken: options.sessionToken,
publicKey: keyPair.publicKey,
channelId,
});
const cleanup = () => {
window.removeEventListener("message", handleMessage);
iframe.remove();
};
const handleMessage = (event: MessageEvent) => {
if (!event.origin.includes("moonpay.com")) return;
const message = parseMessage(event.data, channelId);
if (!message) return;
options.onMessage?.("inbound", message);
if (message.kind === "handshake") {
const ack = { version: 2, meta: { channelId }, kind: "ack" };
options.onMessage?.("outbound", ack);
iframe.contentWindow?.postMessage(JSON.stringify(ack), event.origin);
} else if (message.kind === "complete") {
clearTimeout(timeoutId);
cleanup();
const payload = message.payload as CompletePayload;
if (payload.status === "active" && payload.credentials) {
try {
resolve({
ok: true,
value: {
status: "active",
credentials: decryptCredentials(
payload.credentials,
keyPair.privateKey,
),
},
keyPair,
});
} catch {
resolve({
ok: false,
error: {
kind: "genericError",
message: "Failed to decrypt credentials",
},
});
}
} else if (payload.status === "failed") {
resolve({
ok: true,
value: {
status: "failed",
reason: payload.reason || "Connection failed",
},
keyPair,
});
} else {
resolve({
ok: true,
value: { status: payload.status } as Connection,
keyPair,
});
}
} else if (message.kind === "error") {
clearTimeout(timeoutId);
cleanup();
const payload = message.payload as ErrorPayload;
resolve({
ok: false,
error: { kind: payload.code, message: payload.message },
});
}
};
window.addEventListener("message", handleMessage);
document.body.appendChild(iframe);
timeoutId = setTimeout(() => {
cleanup();
resolve({
ok: false,
error: {
kind: "frameTimeout",
message: "Check frame did not respond in time",
},
});
}, FRAME_TIMEOUT_MS);
});
}
// Connect Frame
export type SetupConnectFrameResult =
| { ok: true; value: { dispose: () => void } }
| { ok: false; error: { kind: string; message: string } };
export function setupConnectFrame(options: {
sessionToken: string;
keyPair: KeyPair;
container: HTMLElement;
theme?: "dark" | "light";
onEvent: (event: ConnectEvent) => void;
onMessage?: (dir: MessageDirection, data: unknown) => void;
}): Promise<SetupConnectFrameResult> {
return new Promise((resolve) => {
const channelId = generateChannelId();
let timeoutId: ReturnType<typeof setTimeout>;
const iframe = document.createElement("iframe");
iframe.style.cssText = "width:100%;height:100%;border:none";
iframe.allow = "camera; microphone";
iframe.src = buildFrameUrl("/platform/v1/connect", {
sessionToken: options.sessionToken,
publicKey: options.keyPair.publicKey,
channelId,
...(options.theme && { theme: options.theme }),
});
const dispose = () => {
window.removeEventListener("message", handleMessage);
iframe.remove();
};
const handleMessage = (event: MessageEvent) => {
if (!event.origin.includes("moonpay.com")) return;
const message = parseMessage(event.data, channelId);
if (!message) return;
options.onMessage?.("inbound", message);
if (message.kind === "handshake") {
const ack = { version: 2, meta: { channelId }, kind: "ack" };
options.onMessage?.("outbound", ack);
iframe.contentWindow?.postMessage(JSON.stringify(ack), event.origin);
} else if (message.kind === "ready") {
clearTimeout(timeoutId);
options.onEvent({ kind: "ready" });
} else if (message.kind === "complete") {
clearTimeout(timeoutId);
const payload = message.payload as CompletePayload;
if (payload.status === "connectionRequired") return;
if (payload.status === "active" && payload.credentials) {
try {
options.onEvent({
kind: "complete",
payload: { frame: { dispose } },
connection: {
status: "active",
credentials: decryptCredentials(
payload.credentials,
options.keyPair.privateKey,
),
},
});
} catch {
options.onEvent({
kind: "error",
payload: {
kind: "genericError",
message: "Failed to decrypt credentials",
},
});
}
} else if (payload.status === "failed") {
options.onEvent({
kind: "complete",
payload: { frame: { dispose } },
connection: {
status: "failed",
reason: payload.reason || "Connection failed",
},
});
} else {
options.onEvent({
kind: "complete",
payload: { frame: { dispose } },
connection: { status: payload.status } as Connection,
});
}
} else if (message.kind === "error") {
clearTimeout(timeoutId);
options.onEvent({
kind: "error",
payload: {
kind: "genericError",
message: (message.payload as ErrorPayload).message,
},
});
}
};
window.addEventListener("message", handleMessage);
options.container.appendChild(iframe);
timeoutId = setTimeout(() => {
dispose();
resolve({
ok: false,
error: {
kind: "frameTimeout",
message: "Connect frame did not respond in time",
},
});
}, FRAME_TIMEOUT_MS);
resolve({ ok: true, value: { dispose } });
});
}
// Apple Pay Frame
export function setupApplePay(
options: SetupApplePayOptions & { clientToken: string },
): Promise<Result<ApplePayFrame, SetupApplePayError>> {
return new Promise((resolve) => {
const channelId = crypto.randomUUID();
let frameOrigin: string | null = null;
const iframe = document.createElement("iframe");
iframe.style.cssText = "width:100%;height:100%;border:none";
iframe.allow = "payment";
const url = new URL("/platform/v1/apple-pay", FRAME_ORIGIN);
url.searchParams.set("clientToken", options.clientToken);
url.searchParams.set("signature", options.quote);
url.searchParams.set("channelId", channelId);
iframe.src = url.toString();
const sendSetQuote = (signature: string) => {
if (!iframe.contentWindow || !frameOrigin) return;
iframe.contentWindow.postMessage(
JSON.stringify({
version: 2,
meta: { channelId },
kind: "setQuote",
payload: { quote: { signature } },
}),
frameOrigin,
);
};
const dispose = () => {
window.removeEventListener("message", handleMessage);
iframe.remove();
};
const handleMessage = (event: MessageEvent) => {
if (!event.origin.includes("moonpay.com")) return;
const message = parseMessage(event.data, channelId);
if (!message) return;
if (message.kind === "handshake") {
frameOrigin = event.origin;
iframe.contentWindow?.postMessage(
JSON.stringify({ version: 2, meta: { channelId }, kind: "ack" }),
event.origin,
);
} else if (message.kind === "ready") {
options.onEvent({ kind: "ready" });
} else if (message.kind === "complete") {
options.onEvent({
kind: "complete",
payload: {
transaction: (message.payload as { transaction: Transaction })
.transaction,
},
});
} else if (message.kind === "error") {
const payload = message.payload as ApplePayEventError;
const errorKind =
payload.kind || (payload as unknown as { code?: string }).code;
if (errorKind === "quoteExpired")
options.onEvent({
kind: "quoteExpired",
payload: { setQuote: sendSetQuote },
});
else if (errorKind === "applePayUnavailable")
options.onEvent({ kind: "unsupported" });
else options.onEvent({ kind: "error", payload });
}
};
window.addEventListener("message", handleMessage);
options.container.appendChild(iframe);
resolve({ ok: true, value: { setQuote: sendSetQuote, dispose } });
});
}
export { generateKeyPair, type KeyPair };