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. 1

    Install and configure

    pnpm add @morva/sdk

    Create 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. 2

    Store where each merchant gets paid

    Whatever your merchant onboarding flow looks like, the two fields createDirectIntent needs are a settlementToken (the ERC-20 contract address on Arbitrum One) and a settlementRecipient(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 viem WalletClientfor the merchant's own wallet, since registering writes an on-chain transaction the merchant signs themselves. See the API reference.

  3. 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_sign under the hood) and native EIP-7702 authorization signing. Most embedded providers with 7702 support expose both directly.

  4. 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 paying

    Do 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. 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 means amountis in that token's units instead). .toFixed(2) silently rounds anything under a cent to "0.00" — indistinguishable from a real bug, since pay()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. 6

    Surface the transaction, not just success

    result.transactionId and result.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 value

This 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 MorvaSigner implementation 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. A SettlementTimeout is neither — leave the order pending, don't mark it failed, see the next item.
  • Catch MorvaSdkError specifically (see the errors reference) and handle InsufficientUnifiedBalance/UnroutableBalance and SettlementTimeout as distinct, expected outcomes — not generic failures. On SettlementTimeout, store err.transactionId and pass it back in as resumeTransactionId on 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.