Skip to main content

Documentation Index

Fetch the complete documentation index at: https://docs.dynamic.xyz/docs/llms.txt

Use this file to discover all available pages before exploring further.

What We’re Building

A Next.js app that pairs Dynamic’s embedded wallets with Iron Finance’s payment rails so a user can:
  • Complete one-time KYC, register their embedded wallet, and add a SEPA bank account
  • Onramp: get a live EUR → USDC quote, execute it, and receive a virtual IBAN (vIBAN) to send a SEPA transfer to. USDC is delivered to the embedded wallet automatically.
  • Offramp: get a live USDC → EUR quote, execute it, and receive a deposit address. Sending USDC to it triggers a SEPA payout to the registered IBAN.
  • View prior onramps and offramps with live status

How It Works

Iron exposes a quote + execute model around its /api/autoramps endpoint:
  1. Quote (GET /api/autoramps/quote) returns a signed, time-limited rate for the requested direction, amount, and rails.
  2. Execute (POST /api/autoramps) creates an autoramp from that quote. The response includes deposit_rails — a vIBAN (for onramps) or a deposit crypto address (for offramps) the user sends funds to.
  3. When the deposit arrives, Iron converts and settles automatically to the destination registered on the autoramp (the user’s embedded wallet for onramps, their IBAN for offramps).
Before a user can ramp, they complete a six-step onboarding:
  1. Create an Iron customer
  2. Complete KYC identity verification (hosted link)
  3. Sign any required compliance documents
  4. Register the Dynamic embedded wallet (signed proof of ownership — Travel Rule)
  5. Add a SEPA bank account
  6. Done
All Iron calls are proxied through your own Next.js API routes so the Iron API key stays server-side. Onboarding state is persisted in Dynamic user metadata so users can resume on any device.

Building the Application

Project setup

Follow the React Quickstart using the Custom setup path: Ethereum (EVM) with viem, without Wagmi. Scaffold with create-next-app and apply the same core packages and DynamicContextProvider setup.
In the Dynamic dashboard: under Chains & Networks enable the networks you’ll use with Iron (e.g. Ethereum, Base). Under Sign-in Methods and Wallets enable Embedded wallets. Under Security → Allowed Origins add the origin you’re running on (for example http://localhost:3000).

Install Dependencies

npm install @dynamic-labs/sdk-react-core @dynamic-labs/ethereum @radix-ui/react-select @radix-ui/react-dialog @tanstack/react-query lucide-react zod

Configure Environment Variables

.env.local
NEXT_PUBLIC_DYNAMIC_ENVIRONMENT_ID=your-dynamic-environment-id
IRON_API_KEY=your-iron-api-key
IRON_ENVIRONMENT=sandbox
IRON_ENVIRONMENT is either sandbox or production. The client derives the Iron API base URL from it (https://api.sandbox.iron.xyz vs https://api.iron.xyz). Ask Iron for a sandbox IRON_API_KEY — in sandbox you can simulate KYC approval without real documents.

Configure Dynamic Providers

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="light"
      settings={{
        environmentId: process.env.NEXT_PUBLIC_DYNAMIC_ENVIRONMENT_ID!,
        walletConnectors: [EthereumWalletConnectors],
      }}
    >
      <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
    </DynamicContextProvider>
  );
}

Server-Side Iron Client

Iron expects an X-API-Key header and an IDEMPOTENCY-KEY on writes. Wrap both in a small server-only client:
src/lib/iron-client.ts
import { randomUUID } from "crypto";

const IRON_API_KEY = process.env.IRON_API_KEY!;
const IRON_BASE_URL =
  process.env.IRON_ENVIRONMENT === "production"
    ? "https://api.iron.xyz"
    : "https://api.sandbox.iron.xyz";

export async function ironFetch<T>(
  path: string,
  options: RequestInit & { idempotent?: boolean } = {}
): Promise<T> {
  const { idempotent, headers, ...rest } = options;
  const res = await fetch(`${IRON_BASE_URL}${path}`, {
    ...rest,
    headers: {
      "Content-Type": "application/json",
      Accept: "application/json",
      "X-API-Key": IRON_API_KEY,
      ...(idempotent ? { "IDEMPOTENCY-KEY": randomUUID() } : {}),
      ...headers,
    },
  });

  if (!res.ok) {
    const body = await res.text();
    throw new Error(`Iron ${res.status}: ${body}`);
  }
  if (res.status === 204) return undefined as T;
  return res.json();
}

Proxy API Routes

Build thin route handlers that forward to Iron. A few representative ones:
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 name = `${body.first_name ?? ""} ${body.last_name ?? ""}`.trim();
  const customer = await ironFetch<{ id: string }>("/api/customers", {
    method: "POST",
    idempotent: true,
    body: JSON.stringify({
      customer_type: "Person",
      email: body.email,
      name: name || body.email.split("@")[0],
    }),
  });
  return NextResponse.json({ data: customer }, { status: 201 });
}
src/app/api/iron/wallets/self-hosted/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(); // { customer_id, blockchain, address, signature, message }
  const wallet = await ironFetch<{ id: string }>(
    "/api/addresses/crypto/selfhosted",
    { method: "POST", idempotent: true, body: JSON.stringify(body) }
  );
  return NextResponse.json({ data: wallet }, { status: 201 });
}
src/app/api/iron/banks/route.ts
import { NextRequest, NextResponse } from "next/server";
import { ironFetch } from "@/lib/iron-client";

export async function POST(req: NextRequest) {
  const b = await req.json();
  const [given, ...rest] = b.account_holder_name.trim().split(/\s+/);
  const fiatAddress = await ironFetch<{ id: string }>("/api/addresses/fiat", {
    method: "POST",
    idempotent: true,
    body: JSON.stringify({
      customer_id: b.customer_id,
      currency: { code: b.currency },
      bank_details: {
        recipient: {
          type: "Individual",
          given_name: given,
          family_name: rest.join(" ") || given,
        },
        provider_name: b.bank_name,
        provider_country: { code: b.bank_country },
        account_identifier: { type: "SEPA", iban: b.iban },
        address: {
          street: b.street,
          city: b.city,
          state: b.state,
          country: { code: b.country },
          postal_code: b.postal_code,
        },
        is_third_party: false,
      },
      label: b.label,
    }),
  });
  return NextResponse.json({ data: fiatAddress }, { status: 201 });
}
Add similar handlers for:
  • POST /api/iron/customers/[id]/kycPOST /api/customers/{id}/identifications (returns a hosted verification URL)
  • GET /api/iron/customers/[id]/signingsGET /api/customers/{id}/required-signings
  • POST /api/iron/customers/[id]/signingsPOST /api/customers/{id}/signings
  • GET /api/iron/customers/[id]/walletsGET /api/addresses/crypto/{customer_id}?filter=All
  • GET /api/iron/customers/[id]/banksGET /api/addresses/fiat/{customer_id}
  • GET /api/iron/customers/[id]/autorampsGET /api/customers/{customer_id}/autoramps (transaction history)

Persist Onboarding State with Dynamic Metadata

Store progress inside Dynamic user metadata so users can resume on any device. Use the useUserUpdateRequest hook to sync.
src/lib/hooks/useKYCMetadata.ts
"use client";

import {
  useDynamicContext,
  useUserUpdateRequest,
} from "@dynamic-labs/sdk-react-core";
import { useCallback, useEffect, useState } from "react";

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

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

export function useKYCMetadata() {
  const { user } = useDynamicContext();
  const { updateUser } = useUserUpdateRequest();
  const [state, setState] = useState<IronMeta>({ step: "customer" });
  const [isLoading, setIsLoading] = useState(true);

  useEffect(() => {
    const iron = (user?.metadata as { iron?: IronMeta } | undefined)?.iron;
    setState(iron ?? { step: "customer" });
    setIsLoading(false);
  }, [user?.userId, user?.metadata]);

  const updateState = useCallback(
    async (updates: Partial<IronMeta>) => {
      const next = { ...state, ...updates };
      setState(next);
      if (user) await updateUser({ metadata: { ...user.metadata, iron: next } });
    },
    [state, user, updateUser]
  );

  return { ...state, isLoading, updateState };
}

Build the Onboarding Flow

The onboarding page walks through the six steps. Each step calls one of your proxied routes and advances step in metadata. Step 1 — Create the Iron customer
const res = await fetch("/api/iron/customers", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({
    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.id, step: "kyc" });
Step 2 — Start KYC
const res = await fetch(`/api/iron/customers/${customerId}/kyc`, {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ return_url: window.location.origin + "/onboard" }),
});
const { data } = await res.json();
await updateState({ kycUrl: data.verification_url ?? data.url });
window.open(data.verification_url ?? data.url, "_blank");
In sandbox, approve programmatically by POSTing { approved: true } to Iron’s sandbox identification endpoint and then auto-signing any required documents. Step 3 — Sign compliance documents
const res = await fetch(`/api/iron/customers/${customerId}/signings`);
const { data: signings } = await res.json();

for (const signing of signings) {
  await fetch(`/api/iron/customers/${customerId}/signings`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      content_id: signing.id,
      content_type: signing.type ?? "Url",
      signed: true,
    }),
  });
}
await updateState({ step: "wallet" });
Step 4 — Register the Dynamic embedded wallet Iron requires a signed proof-of-ownership message. Use the blockchain name Iron expects (Ethereum, Polygon, Arbitrum, Base, Solana, Stellar, Citrea). Iron activates customers asynchronously after signings, so retry briefly if the request comes back Customer is not active.
const address = primaryWallet!.address.toLowerCase();
const date = new Date().toUTCString();
const message = `I am verifying ownership of the wallet address ${address} as customer ${customerId}. This message was signed on ${date} to confirm my control over this wallet.`;
const signature = await primaryWallet!.signMessage(message);

const res = await fetch("/api/iron/wallets/self-hosted", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({
    customer_id: customerId,
    blockchain: "Base",
    address,
    signature,
    message,
  }),
});
const { data } = await res.json();
await updateState({ walletId: data.id, walletAddress: address, step: "bank" });
Step 5 — Add a SEPA bank account
const res = await fetch("/api/iron/banks", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({
    customer_id: customerId,
    currency: "EUR",
    account_holder_name: bankData.accountHolderName,
    iban: bankData.iban,
    bank_name: bankData.bankName,
    bank_country: bankData.bankCountry,
    street: bankData.street,
    city: bankData.city,
    state: bankData.state,
    country: bankData.country,
    postal_code: bankData.postalCode,
    label: "Primary",
  }),
});
const { data } = await res.json();
await updateState({
  bankAccountId: data.id,
  bankIban: bankData.iban,
  step: "complete",
});
Step 6 — Done. The user now has a customer profile, a registered wallet, and a registered IBAN.

Quote + Execute: Unified Ramp Endpoints

A single /api/onramp route handles both fetching a quote and executing it, discriminated by an action field. /api/offramp mirrors the shape for the other direction. This matches the GET /api/autoramps/quotePOST /api/autoramps pattern on Iron.
src/app/api/onramp/route.ts
import { NextRequest, NextResponse } from "next/server";
import { ironFetch } from "@/lib/iron-client";
import { z } from "zod";

const Blockchain = z.enum(["Ethereum", "Polygon", "Arbitrum", "Base", "Solana", "Stellar", "Citrea"]);
const Fiat = z.enum(["USD", "EUR", "GBP", "BRL", "MXN"]);
const Crypto = z.enum(["USDC", "USDT", "USDB", "EURC"]);

const schema = z.discriminatedUnion("action", [
  z.object({
    action: z.literal("quote"),
    customer_id: z.string(),
    source_currency: Fiat,
    destination_currency: Crypto,
    source_amount: z.number().positive().optional(),
    destination_amount: z.number().positive().optional(),
    payment_rail: z.enum(["sepa", "ach", "wire"]),
    wallet_address: z.string(),
    blockchain: Blockchain,
  }),
  z.object({
    action: z.literal("execute"),
    quote_id: z.string(),
    customer_id: z.string(),
    wallet_address: z.string(),
    blockchain: Blockchain,
    source_currency: Fiat,
    destination_currency: Crypto,
  }),
]);

export async function POST(req: NextRequest) {
  const data = schema.parse(await req.json());

  if (data.action === "quote") {
    const params = new URLSearchParams({
      customer_id: data.customer_id,
      source_currency_code: data.source_currency,
      destination_currency_code: data.destination_currency,
      destination_currency_chain: data.blockchain,
      recipient_account: data.wallet_address,
      rate_expiry_policy: "Return",
      expiry_in_hours: "24",
      is_third_party: "false",
      ...(data.source_amount
        ? { amount_in: (data.source_amount / 100).toString() }
        : data.destination_amount
        ? { amount_out: (data.destination_amount / 1_000_000).toString() }
        : {}),
    });
    const quote = await ironFetch(`/api/autoramps/quote?${params}`);
    return NextResponse.json({ data: quote });
  }

  const autoramp = await ironFetch("/api/autoramps", {
    method: "POST",
    idempotent: true,
    body: JSON.stringify({
      customer_id: data.customer_id,
      destination_currency: {
        type: "Crypto",
        blockchain: data.blockchain,
        token: data.destination_currency,
      },
      recipient_account: {
        type: "Crypto",
        chain: data.blockchain,
        address: data.wallet_address,
      },
      source_currencies: [{ type: "Fiat", code: data.source_currency }],
    }),
  });
  return NextResponse.json({ data: autoramp }, { status: 201 });
}
The offramp route is symmetric — crypto source, fiat destination, and recipient_account is the IBAN:
// src/app/api/offramp/route.ts (execute branch)
const autoramp = await ironFetch("/api/autoramps", {
  method: "POST",
  idempotent: true,
  body: JSON.stringify({
    customer_id: data.customer_id,
    destination_currency: { type: "Fiat", code: data.destination_currency },
    recipient_account: {
      type: "Fiat",
      account_identifier: { type: "SEPA", iban: data.bank_account_id },
    },
    source_currencies: [
      { type: "Crypto", blockchain: data.blockchain, token: data.source_currency },
    ],
  }),
});

Drive the Ramp UI from Quote + Execute

The client-side flow: the user picks the direction and amount → you fetch a quote → show the rate → the user confirms → you execute and render the deposit rails from the response.
// 1. Quote
const quoteRes = await fetch("/api/onramp", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({
    action: "quote",
    customer_id,
    source_currency: "EUR",
    destination_currency: "USDC",
    source_amount: eurCents, // e.g. 10000 = €100
    payment_rail: "sepa",
    wallet_address: primaryWallet.address,
    blockchain: "Base",
  }),
});
const { data: quote } = await quoteRes.json();

// 2. Execute with the returned quote_id
const execRes = await fetch("/api/onramp", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({
    action: "execute",
    quote_id: quote.quote_id,
    customer_id,
    wallet_address: primaryWallet.address,
    blockchain: "Base",
    source_currency: "EUR",
    destination_currency: "USDC",
  }),
});
const { data: autoramp } = await execRes.json();
autoramp.deposit_rails[0] holds the rails the user sends funds to:
  • Onramp — a vIBAN (iban, bic, beneficiary_name). The user sends a SEPA transfer; USDC is delivered to their embedded wallet automatically.
  • Offramp — a deposit crypto address. The user sends USDC; EUR lands in their registered IBAN automatically.
<div>
  {autoramp.kind === "Onramp" ? (
    <>
      <p>Send EUR via SEPA to:</p>
      <div>IBAN: <code>{autoramp.deposit_rails[0].iban}</code></div>
      <div>BIC: <code>{autoramp.deposit_rails[0].bic}</code></div>
      <div>Beneficiary: {autoramp.deposit_rails[0].beneficiary_name}</div>
    </>
  ) : (
    <>
      <p>Send {sourceToken} to:</p>
      <code>{autoramp.deposit_rails[0].address}</code>
    </>
  )}
</div>

Transaction History

List prior ramps with GET /api/iron/customers/[id]/autoramps. Each item includes kind, status, the embedded quote, and the deposit_rails so you can render a detail view without re-fetching.

Run the Application

npm run dev
The app is available at http://localhost:3000. Make sure that origin is in Security → Allowed Origins in the Dynamic dashboard.

Conclusion

  • Embedded Wallets — Dynamic handles wallet creation, auth, and message signing; no seed phrases.
  • KYC & Compliance — Iron’s hosted identity flow plus signed proof-of-ownership covers KYC and Travel Rule.
  • Onramp — Users get a live quote and a vIBAN; EUR in via SEPA becomes USDC in their embedded wallet.
  • Offramp — Users get a live quote and a deposit address; USDC sent there becomes EUR in their registered IBAN.
  • Resumable onboarding — All progress is stored in Dynamic user metadata so users can pick up where they left off on any device.

Additional Resources