Integration guide
A full walkthrough for wiring @morva/sdk into your own platform — your own merchant onboarding, your own buyer auth, your own checkout UI. This is the path from "the SDK exists" to "a real buyer paid a real merchant through it."
Before you start
- A Particle Network project (Project ID, Client Key, App ID) — see the quickstart.
- An embedded wallet provider your users already authenticate with, that can produce EIP-7702 authorization signatures (Magic is proven in this repo; see why injected wallets don't work here). If you don't have one yet, Magic is the fastest path — its embedded wallet natively supports
sign7702Authorization. - A decision on direct vs. registry-based merchant settlement config — most integrations start with direct.
The integration, step by step
- 1
Install and configure
pnpm add @morva/sdkCreate one
Morvainstance for your app — not per-request, not per-component. It's stateless beyond holding config, so a module-level singleton is normal:// lib/morva.ts import { createMorva } from "@morva/sdk"; export const morva = createMorva({ particle: { projectId: process.env.NEXT_PUBLIC_PARTICLE_PROJECT_ID!, projectClientKey: process.env.NEXT_PUBLIC_PARTICLE_CLIENT_KEY!, projectAppUuid: process.env.NEXT_PUBLIC_PARTICLE_APP_ID!, }, // registryAddress: "0x...", // add once/if you deploy MorvaRegistry }); - 2
Store where each merchant gets paid
Whatever your merchant onboarding flow looks like, the two fields
createDirectIntentneeds are asettlementToken(the ERC-20 contract address on Arbitrum One) and asettlementRecipient(where funds land). Store these however you already store merchant data — a database row is the common case. There's no SDK call required to "register" a merchant on this path; the settlement config only needs to exist by the time a buyer checks out.Using the registry instead?
Skip straight to
morva.registerMerchant(cfg, walletClient)— it needs a viemWalletClientfor the merchant's own wallet, since registering writes an on-chain transaction the merchant signs themselves. See the API reference. - 3
Build a signer for your buyer's wallet
This is the piece unique to your stack — implement
MorvaSigneragainst whatever embedded wallet provider your app already uses for buyer auth. Here's the complete, working Magic implementation from this repo's reference frontend:import type { Magic } from "magic-sdk"; import { createWalletClient, custom, type Address, type Hex, type SignedAuthorization } from "viem"; import type { AuthorizationRequest, MorvaSigner } from "@morva/sdk"; export class MagicSigner implements MorvaSigner { constructor( private readonly magic: Magic, public readonly address: Address ) {} async signMessage(message: Uint8Array): Promise<Hex> { const walletClient = createWalletClient({ account: this.address, transport: custom(this.magic.rpcProvider), }); return walletClient.signMessage({ account: this.address, message: { raw: message } }); } async signAuthorization(auth: AuthorizationRequest): Promise<SignedAuthorization> { const signed = await this.magic.wallet.sign7702Authorization({ contractAddress: auth.contractAddress, chainId: auth.chainId, nonce: auth.nonce, }); return { address: signed.contractAddress as Address, chainId: signed.chainId, nonce: signed.nonce, r: signed.r as Hex, s: signed.s as Hex, yParity: signed.v >= 27 ? signed.v - 27 : signed.v, }; } }If you're on a different embedded wallet provider, look for the equivalent two capabilities: raw message signing (usually
personal_signunder the hood) and native EIP-7702 authorization signing. Most embedded providers with 7702 support expose both directly. - 4
Connect the buyer when they reach checkout
import { morva } from "@/lib/morva"; import { MagicSigner } from "@/lib/magic-signer"; const signer = new MagicSigner(magic, buyerAddress); const session = await morva.connect(signer); const balance = await session.getUnifiedBalance(); // balance.totalUsd — show this before the buyer commits to payingDo this at checkout time, not at sign-in — constructing the Universal Account is cheap, but there's no reason to do it before a buyer actually needs to pay.
- 5
Build the intent and pay
const intent = morva.createDirectIntent({ amount: order.totalUsd.toFixed(2), orderId: order.id, settlementToken: merchant.settlementToken, settlementRecipient: merchant.settlementRecipient, }); try { const result = await session.pay(intent, { onStatus: (status) => setCheckoutStatus(status), // drive your UI off this }); await markOrderSettled(order.id, result.transactionId, result.explorerUrl); } catch (err) { await markOrderFailed(order.id, err instanceof Error ? err.message : "Unknown error"); // see the errors reference for handling each typed error distinctly }Amount precision
amountis a plain decimal string sent straight through to the settlement token's own transfer — in the token's units, not necessarily USD (only true for USDC and other 1:1 stablecoins; settling in ETH, WETH, or any other ERC-20 meansamountis in that token's units instead)..toFixed(2)silently rounds anything under a cent to"0.00"— indistinguishable from a real bug, sincepay()will happily execute a genuine zero-value transfer. Use enough decimal places that a real price never rounds to zero — match the settlement token's own decimals (6 for USDC, typically 18 for ETH and most other ERC-20s).Record the order as pending before calling
pay(), not after — if your process crashes mid-payment, you want a record that a payment was attempted, not silence. Re-read prices from your own database inside that pending-order step too; never trust a price the client sent you. - 6
Surface the transaction, not just success
result.transactionIdandresult.explorerUrlare what let a buyer (or you, debugging a support ticket) independently verify a payment actually happened. Show the explorer link on your order confirmation screen — don't just show a checkmark and discard the only proof the payment is real.
Testing before you rely on this with real money
LocalSigner(a raw private key, never for a real frontend) is what the SDK's own test suite and end-to-end script use. If you're integration-testing your own checkout flow without a browser, it's the fastest way to drive a real session.pay() call:
import { createMorva, LocalSigner } from "@morva/sdk";
const signer = new LocalSigner(process.env.TEST_BUYER_KEY! as `0x${string}`);
const session = await morva.connect(signer);
// same session.pay() call as production — just funded with real testnet-adjacent valueThis still moves real funds
There is no sandbox/testnet mode for the payment execution path —session.pay() always executes against real Particle infrastructure and real chain state. Test with a small, deliberately funded key, and confirm one real payment end-to-end (checking the explorer link, not just that pay() resolved) before shipping.
Before you ship
- Confirm your
MorvaSignerimplementation never has access to a raw private key — only your embedded provider's own signing methods. - Re-read prices/settlement config from your own trusted storage at payment time — never from client input.
- Record orders as pending before calling
pay(), and settled/failed after, so a crash mid-payment doesn't leave you with no record. ASettlementTimeoutis neither — leave the order pending, don't mark it failed, see the next item. - Catch
MorvaSdkErrorspecifically (see the errors reference) and handleInsufficientUnifiedBalance/UnroutableBalanceandSettlementTimeoutas distinct, expected outcomes — not generic failures. OnSettlementTimeout, storeerr.transactionIdand pass it back in asresumeTransactionIdon retry — otherwise a retry submits a second transfer, and a buyer whose first payment does land gets charged twice. - Show the transaction explorer link somewhere the buyer can actually find it after paying.