Skip to main content

What We’re Building

A React (Next.js) app that connects Dynamic’s embedded wallets to Iron’s payment rails, allowing users to:
  • Complete KYC onboarding and link their bank account
  • Offramp: Convert stablecoins (e.g. USDC) to fiat currency (e.g. EUR) via SEPA — send crypto to an Iron-managed wallet address and receive EUR in your bank account
  • Onramp: Convert fiat currency (e.g. EUR) to stablecoins (e.g. USDC) — deposit EUR to a virtual IBAN (vIBAN) and receive USDC in your embedded wallet
  • View transaction history across onramps and offramps

How It Works

Iron uses an autoramp model: you create a persistent conversion rule that links a source and destination account. Once set up, any deposit to the source automatically converts and settles to the destination — no per-transaction execution needed. 1. One-time onboarding — Before a user can ramp, they must:
  1. Create a customer profile with Iron
  2. Complete KYC identity verification (via Iron’s hosted link)
  3. Sign required compliance documents
  4. Register their Dynamic embedded wallet with Iron (for Travel Rule compliance)
  5. Add a SEPA bank account (IBAN)
2. Autoramp setup — Once onboarded, the user creates an autoramp for each direction they want to use:
  • Offramp autoramp: Specifies crypto source (e.g. USDC on Ethereum) → fiat destination (e.g. EUR to their IBAN). Iron returns a wallet address — the user sends USDC there and EUR lands in their bank.
  • Onramp autoramp: Specifies fiat source (e.g. EUR) → crypto destination (e.g. USDC to their embedded wallet). Iron returns a virtual IBAN (vIBAN) — the user sends a SEPA transfer there and USDC lands in their wallet.
3. Deposits trigger automatic conversion — Each autoramp address/vIBAN is persistent and reusable. Iron handles the conversion and settlement automatically on every deposit. All Iron API calls are proxied through your own Next.js API routes, which verify the Dynamic JWT before forwarding requests. Iron credentials never touch the client.

Building the Application

Project Setup

Start by creating a new Dynamic project with Next.js:
npx create-dynamic-app@latest euro-ramp --framework nextjs --library viem --wagmi false --chains ethereum --pm npm
cd euro-ramp

Install Dependencies

npm install @radix-ui/react-select @tanstack/react-query lucide-react

Configure Environment Variables

Create a .env.local file with your credentials:
.env.local
NEXT_PUBLIC_DYNAMIC_ENVIRONMENT_ID=your-dynamic-environment-id
IRON_API_KEY=your-iron-api-key
IRON_API_URL=https://api.iron.fi
NEXT_PUBLIC_IRON_ENVIRONMENT=sandbox
You can find your Dynamic Environment ID in the Dynamic dashboard under Developer Settings → SDK & API Keys. Contact Iron to obtain your IRON_API_KEY. Set NEXT_PUBLIC_IRON_ENVIRONMENT=sandbox while testing — in sandbox mode, KYC approvals can be simulated without real documents.

Configure Dynamic Providers

Update src/lib/providers.tsx to set up Dynamic with React Query:
src/lib/providers.tsx
"use client";

import { DynamicContextProvider } from "@dynamic-labs/sdk-react-core";
import { EthereumWalletConnectors } from "@dynamic-labs/ethereum";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 1000 * 60 * 5,
      refetchOnWindowFocus: false,
    },
  },
});

export default function Providers({ children }: { children: React.ReactNode }) {
  return (
    <DynamicContextProvider
      theme="auto"
      settings={{
        environmentId: process.env.NEXT_PUBLIC_DYNAMIC_ENVIRONMENT_ID!,
        walletConnectors: [EthereumWalletConnectors],
      }}
    >
      <QueryClientProvider client={queryClient}>
        {children}
      </QueryClientProvider>
    </DynamicContextProvider>
  );
}

Create Server-Side Iron API Routes

All calls to Iron are made from Next.js API routes that verify the Dynamic JWT first. This keeps your Iron credentials server-side only. Create a shared Iron client helper at src/lib/iron-client.ts:
src/lib/iron-client.ts
const IRON_API_URL = process.env.IRON_API_URL!;
const IRON_API_KEY = process.env.IRON_API_KEY!;

export async function ironFetch(
  path: string,
  options: RequestInit = {}
): Promise<Response> {
  return fetch(`${IRON_API_URL}${path}`, {
    ...options,
    headers: {
      "Content-Type": "application/json",
      Authorization: `Bearer ${IRON_API_KEY}`,
      ...options.headers,
    },
  });
}
Then create route handlers for each Iron resource. For example, src/app/api/iron/customers/route.ts:
src/app/api/iron/customers/route.ts
import { NextRequest, NextResponse } from "next/server";
import { ironFetch } from "@/lib/iron-client";

export async function POST(req: NextRequest) {
  const body = await req.json();
  const res = await ironFetch("/customers", {
    method: "POST",
    body: JSON.stringify(body),
  });

  const data = await res.json();
  return NextResponse.json(data, { status: res.status });
}
Create similar route files for:
  • /api/iron/customers/[id]/wallets — register a crypto wallet with an Iron customer (required for Travel Rule compliance)
  • /api/iron/customers/[id]/banks — add a SEPA bank account (fiat address)
  • /api/iron/autoramps — create an onramp or offramp autoramp
  • /api/iron/autoramps/[id] — fetch autoramp status and deposit details
  • /api/iron/fiatcurrencies — list supported fiat currencies

Persist Onboarding State with Dynamic Metadata

Iron’s onboarding involves multiple steps. Use Dynamic’s user metadata to persist progress across sessions so users don’t have to restart if they close the tab. Create src/lib/hooks/useKYCMetadata.ts:
src/lib/hooks/useKYCMetadata.ts
import { useDynamicContext } from "@dynamic-labs/sdk-react-core";
import { useCallback, useEffect, useState } from "react";

export type OnboardStep =
  | "customer"
  | "kyc"
  | "signings"
  | "wallet"
  | "bank"
  | "complete";

interface KYCMetadata {
  customerId?: string;
  walletId?: string;
  bankAccountId?: string;
  kycUrl?: string;
  step?: OnboardStep;
  walletAddress?: string;
  bankIban?: string;
}

export function useKYCMetadata() {
  const { user, sdkHasLoaded } = useDynamicContext();
  const [state, setState] = useState<KYCMetadata>({});
  const [isLoading, setIsLoading] = useState(true);
  const [isSyncing, setIsSyncing] = useState(false);

  // Load from Dynamic user metadata on mount
  useEffect(() => {
    if (!sdkHasLoaded) return;
    const meta = (user?.metadata as KYCMetadata) ?? {};
    setState(meta);
    setIsLoading(false);
  }, [user, sdkHasLoaded]);

  const updateState = useCallback(
    async (updates: Partial<KYCMetadata>) => {
      setIsSyncing(true);
      const next = { ...state, ...updates };
      setState(next);
      // Persist to Dynamic user metadata via your own backend or Dynamic's SDK
      // Example: await dynamicClient.users.updateMetadata(user.id, next);
      setIsSyncing(false);
    },
    [state]
  );

  const setStep = useCallback(
    (step: OnboardStep) => updateState({ step }),
    [updateState]
  );

  const reset = useCallback(async () => {
    await updateState({
      customerId: undefined,
      walletId: undefined,
      bankAccountId: undefined,
      kycUrl: undefined,
      step: "customer",
      walletAddress: undefined,
      bankIban: undefined,
    });
  }, [updateState]);

  return {
    ...state,
    isLoading,
    isSyncing,
    updateState,
    setStep,
    reset,
  };
}

Build the Onboarding Flow

The onboarding page (src/app/onboard/page.tsx) walks users through six steps before they can ramp. Each step calls your proxied Iron API routes. Step 1 — Create Iron Customer Collect basic user details and create an Iron customer record:
const handleCreateCustomer = async () => {
  const res = await fetch("/api/iron/customers", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      type: "individual",
      email: user.email,
      first_name: formData.firstName,
      last_name: formData.lastName,
      country_code: formData.countryCode,
      date_of_birth: formData.dateOfBirth,
      phone_number: formData.phoneNumber,
    }),
  });
  const data = await res.json();
  await updateState({ customerId: data.data.id, step: "kyc" });
};
Step 2 — KYC Verification Iron returns a KYC URL. Redirect the user to complete identity verification, then poll or use a webhook to detect approval:
const handleStartKYC = async () => {
  const res = await fetch(`/api/iron/customers/${customerId}/kyc`, {
    method: "POST",
  });
  const data = await res.json();
  await updateState({ kycUrl: data.data.url });
  window.open(data.data.url, "_blank");
};
In sandbox mode, you can approve KYC programmatically without real documents. Step 3 — Sign Compliance Documents After KYC, Iron may require the user to sign compliance documents:
const handleCheckSignings = async () => {
  const res = await fetch(`/api/iron/customers/${customerId}/signings`);
  const data = await res.json();
  const pending = data.data.filter((s: Signing) => !s.signed_at);
  setRequiredSignings(pending);
};
Present each unsigned document (URL or inline text) and mark it signed via the Iron API. Step 4 — Register Dynamic Wallet with Iron Link the user’s Dynamic embedded wallet address to their Iron customer profile. You’ll need to sign a message with the wallet to prove ownership:
const handleRegisterWallet = async () => {
  const address = primaryWallet?.address;
  const message = `Register wallet ${address} for customer ${customerId}`;
  const signature = await primaryWallet?.signMessage(message);

  const res = await fetch(`/api/iron/customers/${customerId}/wallets`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      address,
      blockchain: "ethereum",
      signature,
      message,
    }),
  });
  const data = await res.json();
  await updateState({
    walletId: data.data.id,
    walletAddress: address,
    step: "bank",
  });
};
Step 5 — Add SEPA Bank Account Collect the user’s IBAN and register it with Iron:
const handleAddBank = async () => {
  const res = await fetch(`/api/iron/customers/${customerId}/banks`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      account_holder_name: bankData.accountHolderName,
      iban: bankData.iban,
      bank_name: bankData.bankName,
      bank_country: bankData.bankCountry,
      address: {
        street: bankData.street,
        city: bankData.city,
        country: bankData.country,
        postal_code: bankData.postalCode,
      },
    }),
  });
  const data = await res.json();
  await updateState({
    bankAccountId: data.data.id,
    bankIban: bankData.iban,
    step: "complete",
  });
};
Step 6 — Complete Display a summary of the registered wallet and bank account. Users are now ready to onramp and offramp.

Build the Ramp Interface

With onboarding complete, src/app/ramp/page.tsx lets users create autoramps for either direction. The UI has two tabs: Offramp (crypto → fiat) and Onramp (fiat → crypto). Iron’s autoramp model is persistent — once created, the returned address or vIBAN can be used for repeated deposits. There’s no per-transaction “execute” step. Creating an Offramp Autoramp (Crypto → Fiat) The user specifies the source crypto token, blockchain, and which registered bank account should receive the fiat payout. Iron returns a wallet address — the user sends crypto there and EUR is automatically deposited to their IBAN.
const handleCreateOfframpAutoramp = async () => {
  const selectedBank = registeredBanks[selectedBankIndex];
  const iban = selectedBank?.iban || selectedBank?.account_identifier?.iban;

  const res = await fetch("/api/iron/autoramps", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      customer_id: customerId,
      source: {
        currency: selectedToken,    // e.g. "USDC"
        blockchain: selectedChain,  // e.g. "ethereum"
      },
      destination: {
        currency: selectedFiatCurrency, // e.g. "EUR"
        bank_account_identifier: { iban },
      },
    }),
  });

  const data = await res.json();
  setAutoramp(data.data);
};
The response includes deposit_rails — the wallet address the user should send crypto to:
<div className="autoramp-details">
  <p>Send {selectedToken} to this address:</p>
  <code>{autoramp.deposit_rails?.address}</code>
  <p>EUR will be deposited to your registered bank account automatically.</p>
</div>
Creating an Onramp Autoramp (Fiat → Crypto) The user specifies the destination crypto token, blockchain, and the registered wallet that should receive the crypto. Iron returns a virtual IBAN (vIBAN) — the user sends a SEPA transfer there and USDC is automatically delivered to their wallet.
const handleCreateOnrampAutoramp = async () => {
  const address = primaryWallet?.address;

  const res = await fetch("/api/iron/autoramps", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      customer_id: customerId,
      source: {
        currency: selectedFiatCurrency, // e.g. "EUR"
      },
      destination: {
        currency: selectedToken,   // e.g. "USDC"
        blockchain: selectedChain, // e.g. "ethereum"
        wallet_address: address,
      },
    }),
  });

  const data = await res.json();
  setAutoramp(data.data);
};
The response includes deposit_rails — the vIBAN the user should send a SEPA transfer to:
<div className="autoramp-details">
  <p>Send EUR via SEPA to:</p>
  <div>IBAN: <code>{autoramp.deposit_rails?.iban}</code></div>
  <div>Bank: {autoramp.deposit_rails?.bank_name}</div>
  <div>Account holder: {autoramp.deposit_rails?.account_holder_name}</div>
  <p>{selectedToken} will be delivered to your wallet automatically.</p>
</div>
Fetching Autoramp Status Poll the autoramp to check status and retrieve updated deposit details:
const fetchAutoramp = async (autorampId: string) => {
  const res = await fetch(`/api/iron/autoramps/${autorampId}`);
  const data = await res.json();
  setAutoramp(data.data);
};
The status field reflects the current state: active, pending, paused, or cancelled. An active autoramp is ready to receive deposits.

Load Registered Wallets and Banks

Fetch the user’s registered accounts from Iron to populate the dropdowns in the ramp UI:
const fetchRegisteredAccounts = async () => {
  const [walletsRes, banksRes] = await Promise.all([
    fetch(`/api/iron/customers/${customerId}/wallets`),
    fetch(`/api/iron/customers/${customerId}/banks`),
  ]);

  const walletsData = await walletsRes.json();
  const banksData = await banksRes.json();

  setRegisteredWallets(walletsData.data?.data ?? walletsData.data ?? []);
  setRegisteredBanks(banksData.data?.data ?? banksData.data ?? []);
};

Run the Application

npm run dev
The application will be available at http://localhost:3000. Add http://localhost:3000 to the CORS origins in your Dynamic dashboard under Developer Settings → CORS Origins.

Conclusion

This integration demonstrates how Dynamic’s embedded wallets combine with Iron’s payment infrastructure to deliver a compliant onramp and offramp experience:
  • Embedded Wallets — Dynamic handles wallet creation, authentication, and message signing; no seed phrase management for users
  • KYC & Compliance — Iron’s hosted onboarding flow handles identity verification, document signing, and Travel Rule compliance
  • Offramp — Users send USDC to an Iron-managed wallet address and receive EUR via SEPA directly in their bank account
  • Onramp — Users send EUR via SEPA to a virtual IBAN and receive USDC in their Dynamic embedded wallet
  • Persistent Autoramps — Conversion rules are set up once and reused for every subsequent deposit
  • Persistent State — Dynamic user metadata keeps onboarding progress safe across sessions

Additional Resources