@meldcrypto/sdk
v0.2.0
Published
Meld SDK — embed crypto on/off-ramp provider widgets (Mercuryo card, …) into your checkout.
Maintainers
Readme
Meld SDK
Embed crypto on/off-ramp provider widgets into your own checkout with one uniform call:
Meld.mount(order, hostElement, handlers). Create the order on your backend (your Meld API
key never reaches the browser), pass the response to Meld.mount on your frontend, and the
SDK loads the right provider widget, mounts its cross-origin iframe into a container you own,
and relays lifecycle events back to you.
The SDK is a container manager and event relay: it never renders card input, never reads or transports PAN/CVC, and never reaches into the provider's iframe — card capture happens entirely on the provider's PCI surface.
Which adapter runs is decided internally from the order's (paymentMethodType, renderMode),
so adding a provider is never a public-API change. Supported today:
Mercuryo — card.
Installation
npm install @meldcrypto/sdkimport Meld from '@meldcrypto/sdk';
Meld.configure({ environment: 'sandbox' }); // or 'production'Use it with a bundler (Webpack / Vite / etc.) — the SDK is bundled into your app. If your
page sets a CSP, add the provider hosts from
requiredCsp to your directives.
Integration
Two calls, the same for every provider: your backend creates the order, and your
frontend passes the response verbatim to Meld.mount.
1 · Backend — create the order
POST {base}/crypto/order/headless with Authorization: BASIC <your-api-key> and a
Meld-Version header. Base URL: https://api-sb.meld.io (sandbox) /
https://api.meld.io (production). The request's provider/payment-method fields are
provider-specific — see Providers. Return the response to your frontend
untouched; the SDK reads what it needs from it:
{
"id": "…",
"paymentMethodType": "…",
"paymentMethodResponseDetails": {
"serviceProviderWidgetUrl": "…",
"renderMode": "…"
}
}paymentMethodResponseDetails carries whatever the chosen provider's adapter needs (a
signed widget URL, a session token, …) — fields vary by provider. Don't read or reshape it;
pass the whole order through and the SDK picks out what it needs.
2 · Frontend — mount the order
const order = await fetch('/your-backend/order').then(r => r.json()); // step 1's response
if (!Meld.capabilities(order).embeddable) {
// not an embeddable order for this SDK — handle it outside the SDK
}
const handle = Meld.mount(order, document.getElementById('widget-container'), {
onReady: (e) => hideSpinner(), // e = { orderId }
onPaymentSubmitted: (e) => showProcessing(), // ⚠ UX hint — settlement is YOUR webhook, not this
onStatusChange: (s) => { if (s.status === 'completed') showOrderComplete(); },
onCancel: (e) => showRetryCta(), // e = { orderId }
onError: (e) => showError(e.message), // e = { orderId, code, message, detail?, recoverable }
});3 · Tear down
handle.unmount(); // removes the iframe; call on navigation / modal closeLoading & lifecycle
- Between
mount()andonReadythe widget is loading (provider script + iframe first paint) — typically a second or two, butonReadyis synthesized with fallback timers, so don't assume it's instant. Show a spinner/skeleton onmount()and clear it ononReadyoronError(an error can arrive instead of ready). - Pair
mountwithunmount. Callhandle.unmount()when the widget goes away (navigation, modal close, component unmount). In React, call it in the effect cleanup — this also makes Strict Mode's mount→unmount→mount cycle safe. - Mounting into a container that already holds a widget clears it first (one widget per
container), so a stray double-
mount()won't stack two iframes — but pairing withunmountis still the correct pattern.
Events
| Event | Fires when | Do |
|---|---|---|
| onReady | Widget mounted & interactive (native or synthesized) | Hide spinner |
| onPaymentSubmitted | User finished/submitted the provider payment flow (UX hint only) | Show "processing" |
| onStatusChange | Provider order status changed; payload { orderId, status, providerStatus, raw } where status is pending | completed | failed | cancelled | React to status; completed = provider "order complete" (still not settlement) |
| onCancel | Provider reported a cancelled status | Retry CTA |
| onError | Script load failure or terminal failed status | Show error; recoverable says retry vs. new order |
status is normalized across providers — you code against completed/failed/etc., never
a provider's raw status string (that's available in providerStatus for logging). A
terminal failed also fires onError, and cancelled also fires onCancel, so handlers
that only listen for those still work.
Every callback carries the order it relates to — onReady/onPaymentSubmitted/onCancel
receive { orderId }, and onStatusChange/onError carry it on their payload — so an app
driving several orders at once can tell them apart.
Which events a provider emits varies: onStatusChange fires only for providers that surface
interim/terminal status (Mercuryo does), while onPaymentSubmitted/onCancel/onError are
the common lifecycle signals. Whatever the client events say, settlement is always the Meld
webhook (below) — so a robust integration works off onError/onCancel plus the webhook,
treating the rest as UX hints.
To tear the widget down yourself (navigation, modal close, user backs out), call
handle.unmount() — see Tear down. That removes the iframe but does not
emit onCancel; the lifecycle events fire only from provider-driven transitions.
Settlement — webhook, never the SDK
Neither onPaymentSubmitted nor onStatusChange with status: 'completed' is settlement —
both are client-side UX signals from the provider widget. Mark the order paid only when your
backend receives Meld's TRANSACTION_STATUS_CHANGED webhook. Show "processing", not
"success", until then. (The event is named onPaymentSubmitted, not onComplete, for
exactly this reason — submitted ≠ settled.)
Content-Security-Policy
If your page sets a CSP, ask the SDK what each provider needs rather than hard-coding hosts
or permissions. requiredCsp takes any list of providers and returns the merged, de-duped
directives for the environment set in Meld.configure — everything is read from the SDK's
provider registry, so there's nothing provider-specific for you to maintain:
const csp = Meld.requiredCsp(['MERCURYO']); // example — the result for Mercuryo, sandbox
// {
// frameSrc: ['https://sandbox-widget.mrcr.io', 'https://sandbox-exchange.mrcr.io'],
// scriptSrc: ['https://sandbox-widget.mrcr.io/embed.2.1.js'],
// iframeAllow: ['camera']
// }Map the result onto your CSP header and the iframe's allow attribute:
| Field | Use |
|---|---|
| frameSrc | append to your frame-src directive |
| scriptSrc | append to your script-src directive |
| iframeAllow | the container iframe's allow attribute |
Hosts and permissions differ between sandbox and production; calling requiredCsp
after Meld.configure({ environment }) returns the correct set, so you don't branch on
environment yourself.
The environment of a signed order is authoritative — if you mount an order whose environment differs from the one passed to
Meld.configure(e.g. a production order while still configured forsandbox), the SDK logs aconsole.warn, since your CSP was built fromrequiredCspfor the other environment and would block the widget. CallMeld.configure({ environment })to match the order before generating your CSP.
API reference
Meld.configure(options)—{ environment: 'sandbox' | 'production' }.Meld.mount(order, hostElement, handlers)→handle— mounts the provider widget;handle.unmount()removes it.Meld.capabilities(order)→{ embeddable, surface, requiresUserGesture }— guard withembeddablebefore callingmount.Meld.requiredCsp(providers)→{ frameSrc, scriptSrc, iframeAllow }— CSP directives for the enabled providers.
Providers
The core integration above is identical for every provider. This section holds the
provider-specific pieces: which fields your backend sends, and any provider onboarding. Each
supported provider is its own subsection; new providers appear here without changing the
core API or your mount call.
Mercuryo — card
Embedded iframe (renderMode: IFRAME). The SDK loads Mercuryo's widget script and mounts it
from the signed URL in the order — you don't touch those internals.
Pre-flight — do these first, or a correct integration still fails:
- Allowlist your embedding origin on the Mercuryo widget (exact, HTTPS origin). If it's not allowlisted the iframe is silently refused — the most opaque failure here, since it looks like a code bug. (Sandbox is also IP-allowlisted.)
- The customer must have APPROVED Sumsub KYC linked to their Meld customer before the order — otherwise order creation fails with
KYC_NOT_COMPLETED. (Details below.)- Headless onramps enabled for your Meld account.
These are onboarding/account steps, not code — but they surface as code-looking failures mid-integration, so confirm them up front.
Create the order (POST /crypto/order/headless):
curl -X POST https://api-sb.meld.io/crypto/order/headless \
-H "Authorization: BASIC $MELD_API_KEY" \
-H "Meld-Version: 2026-05-01" \
-H "X-Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{
"customerId": "<meld-customer-id>",
"externalOrderId": "<your-unique-order-id>",
"sessionType": "BUY",
"serviceProvider": "MERCURYO",
"paymentMethodType": "CREDIT_DEBIT_CARD",
"sourceCurrencyCode": "USD",
"sourceAmount": "15",
"destinationCurrencyCode": "BTC",
"destinationWalletAddress": "<wallet-address>",
"countryCode": "US",
"clientIpAddress": "<end-user-public-ip>"
}'- No
redirectUrl— Mercuryo card orders are iframe-mode and reject one withINVALID_REDIRECT_URL. - Pass the end user's public IP as
clientIpAddress. Mercuryo binds the widget signature to it and recomputes it from the IP the user's browser presents to the widget host. A mismatch (your backend's IP viaX-Forwarded-For, or an IPv4/IPv6 family flip) surfaces in the widget as "wallet address invalid".
Quote (optional, to price your checkout UI): POST /payments/crypto/quote with the same
corridor fields. Pass ?integrationMode=HEADLESS so only headless-capable providers are
returned.
Onboarding & prerequisites:
- KYC (out-of-band, beforehand): the customer needs a completed Sumsub verification
(status APPROVED) linked to their Meld customer — done either via Meld's Unified KYC or
your own KYC flow with the Sumsub token shared to Meld.
POST /crypto/order/headlessdoesn't run or wait on KYC; because KYC-token sharing is enabled between Meld and Mercuryo, Meld mints a fresh Mercuryo-scoped share token on the fly at order creation and embeds it in the signed widget URL, so the widget skips its own KYC. No APPROVED verification → creation fails withKYC_NOT_COMPLETED. - Origin allowlist: your exact embedding origin must be registered on the Mercuryo
widget, and only HTTPS origins are honored —
http://origins (including localhost) never make it into Mercuryo'sframe-ancestorsheader, so the frame is refused. Sandbox is additionally IP-allowlisted; a 403 on the widget script means the allowlist, not the SDK. This is an onboarding step, not a code change. - Camera: Mercuryo's in-iframe KYC liveness needs
camerain the iframeallowattribute — surfaced viarequiredCsp().iframeAllow.
