Skip to main content
In this quickstart, you build a web app that:
  • Authenticates users with a one-time passcode (OTP) sent by email
  • Creates a user-owned wallet and connects it to your app
  • Displays the wallet address and the USDC balance it holds

Prerequisites

Before you begin, ensure you have:

Step 1. Get Mailtrap SMTP credentials

In this step, you copy SMTP credentials from Mailtrap so Circle can send OTP codes to users.
  1. Log in to your Mailtrap account.
  2. In the left navigation, click Transactional → Sandboxes.
  3. Open the default sandbox project (My Sandbox) or create a new one.
  4. In the sandbox view, select the SMTP Settings tab.
  5. Copy the following SMTP values:
    • Host
    • Port
    • Username
    • Password
    You need them for the next step.

Step 2. Configure the Circle Console

In this step, you add your Mailtrap SMTP credentials to the Circle Console so Circle can send emails with OTP codes. You also obtain your App ID, which identifies your user-controlled wallets configuration in the Circle Console.
  1. Log in to the Circle Developer Console.
  2. Navigate to Wallets → User Controlled → Configurator.
  3. Under Authentication Methods, click Email.
  4. Enter a From email address (for example, no-reply@example.com).
  5. Enter the SMTP values you copied from Mailtrap:
    • Host
    • Port
    • Username
    • Password
  6. (Optional) Customize your OTP email from, subject, and message body.
  7. Go to the Configurator page and copy your App ID. You need it for the next step.

Step 3. Create the web application

In this step, you create a web app that lets users authenticate with an OTP code sent to their email address, and create a blockchain wallet.

3.1. Create the Next.js project

In your terminal:
npx create-next-app@latest circle-email-otp --yes
cd circle-email-otp

3.2. Install dependencies

Install the user-controlled wallets Web SDK:
npm install @circle-fin/w3s-pw-web-sdk

3.3. Add environment variables

Create a .env.local file in your project directory:
touch .env.local
Open the .env.local file and add the following:
.env.local
CIRCLE_API_KEY=<YOUR_CIRCLE_API_KEY>
NEXT_PUBLIC_CIRCLE_APP_ID=<YOUR_CIRCLE_APP_ID>
  • YOUR_CIRCLE_API_KEY is your Circle Developer API key.
  • YOUR_CIRCLE_APP_ID is the Circle Wallet App ID obtained in Step 2.

3.4. Simplify the default layout

Replace the contents of app/layout.tsx with the minimal layout below:
app/layout.tsx
// app/layout.tsx
export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en">
      <body>{children}</body>
    </html>
  );
}
Next.js requires an app/layout.tsx file, but the default one created by create-next-app includes fonts and styling that can cause build errors in some environments.

3.5. Add unified backend route

Create a file named app/api/endpoints/route.ts and add the code below:
app/api/endpoints/route.ts
// app/api/endpoints/route.ts
import { NextResponse } from "next/server";

const CIRCLE_BASE_URL =
  process.env.NEXT_PUBLIC_CIRCLE_BASE_URL ?? "https://api.circle.com";
const CIRCLE_API_KEY = process.env.CIRCLE_API_KEY as string;

export async function POST(request: Request) {
  try {
    const body = await request.json();
    const { action, ...params } = body ?? {};

    if (!action) {
      return NextResponse.json({ error: "Missing action" }, { status: 400 });
    }

    switch (action) {
      case "requestEmailOtp": {
        const { deviceId, email } = params;
        if (!deviceId || !email) {
          return NextResponse.json(
            { error: "Missing deviceId or email" },
            { status: 400 },
          );
        }

        const response = await fetch(
          `${CIRCLE_BASE_URL}/v1/w3s/users/email/token`,
          {
            method: "POST",
            headers: {
              "Content-Type": "application/json",
              Authorization: `Bearer ${CIRCLE_API_KEY}`,
            },
            body: JSON.stringify({
              idempotencyKey: crypto.randomUUID(),
              deviceId,
              email,
            }),
          },
        );

        const data = await response.json();
        if (!response.ok) {
          return NextResponse.json(data, { status: response.status });
        }

        // { deviceToken, deviceEncryptionKey, otpToken }
        return NextResponse.json(data.data, { status: 200 });
      }

      case "initializeUser": {
        const { userToken } = params;
        if (!userToken) {
          return NextResponse.json(
            { error: "Missing userToken" },
            { status: 400 },
          );
        }

        const response = await fetch(
          `${CIRCLE_BASE_URL}/v1/w3s/user/initialize`,
          {
            method: "POST",
            headers: {
              "Content-Type": "application/json",
              Authorization: `Bearer ${CIRCLE_API_KEY}`,
              "X-User-Token": userToken,
            },
            body: JSON.stringify({
              idempotencyKey: crypto.randomUUID(),
              accountType: "SCA",
              blockchains: ["ARC-TESTNET"],
            }),
          },
        );

        const data = await response.json();

        if (!response.ok) {
          // e.g. 155106 user already initialized
          return NextResponse.json(data, { status: response.status });
        }

        // { challengeId }
        return NextResponse.json(data.data, { status: 200 });
      }

      case "listWallets": {
        const { userToken } = params;
        if (!userToken) {
          return NextResponse.json(
            { error: "Missing userToken" },
            { status: 400 },
          );
        }

        const response = await fetch(`${CIRCLE_BASE_URL}/v1/w3s/wallets`, {
          method: "GET",
          headers: {
            accept: "application/json",
            "content-type": "application/json",
            Authorization: `Bearer ${CIRCLE_API_KEY}`,
            "X-User-Token": userToken,
          },
        });

        const data = await response.json();

        if (!response.ok) {
          return NextResponse.json(data, { status: response.status });
        }

        // { wallets: [...] }
        return NextResponse.json(data.data, { status: 200 });
      }

      case "getTokenBalance": {
        const { userToken, walletId } = params;
        if (!userToken || !walletId) {
          return NextResponse.json(
            { error: "Missing userToken or walletId" },
            { status: 400 },
          );
        }

        const response = await fetch(
          `${CIRCLE_BASE_URL}/v1/w3s/wallets/${walletId}/balances`,
          {
            method: "GET",
            headers: {
              accept: "application/json",
              Authorization: `Bearer ${CIRCLE_API_KEY}`,
              "X-User-Token": userToken,
            },
          },
        );

        const data = await response.json();

        if (!response.ok) {
          return NextResponse.json(data, { status: response.status });
        }

        // { tokenBalances: [...] }
        return NextResponse.json(data.data, { status: 200 });
      }

      default:
        return NextResponse.json(
          { error: `Unknown action: ${action}` },
          { status: 400 },
        );
    }
  } catch (error) {
    console.error("Error in /api/endpoints:", error);
    return NextResponse.json(
      { error: "Internal server error" },
      { status: 500 },
    );
  }
}
This route serves as a single backend entry point for all Circle API endpoints used by the app, mapping frontend actions to thin wrapper handlers that call the corresponding endpoints:
HandlerDescription
requestEmailOtpcalls POST /v1/w3s/users/email/token to initiate email authentication and return temporary verification tokens required by the Web SDK to verify the OTP entered by the user.
initializeUsercalls POST /v1/w3s/user/initialize to create or initialize a user and return a challengeId required for wallet creation.
listWalletscalls GET /v1/w3s/wallets to retrieve the wallets associated with the authenticated user.
getTokenBalancecalls GET /v1/w3s/wallets//balances to retrieve digital asset balances for the specified user-controlled wallet.
This quickstart calls listWallets and getTokenBalance directly for simplicity. In production, apps typically store wallet and balance data in a backend database and keep it in sync using Circle webhooks for scalability.
To understand how the request fields and response data for these handlers and their corresponding endpoints are used, follow the app flow in Step 4 below.

​3.6. Add UI and frontend code

Replace the contents of app/page.tsx with the code below:
app/page.tsx
// app/page.tsx
"use client";

import { useEffect, useRef, useState } from "react";
import { W3SSdk } from "@circle-fin/w3s-pw-web-sdk";

const appId = process.env.NEXT_PUBLIC_CIRCLE_APP_ID as string;

type LoginResult = {
  userToken: string;
  encryptionKey: string;
};

type Wallet = {
  id: string;
  address: string;
  blockchain: string;
  [key: string]: unknown;
};

export default function HomePage() {
  const sdkRef = useRef<W3SSdk | null>(null);

  const [sdkReady, setSdkReady] = useState(false);
  const [deviceId, setDeviceId] = useState<string>("");
  const [deviceIdLoading, setDeviceIdLoading] = useState(false);

  const [email, setEmail] = useState<string>("");

  const [deviceToken, setDeviceToken] = useState<string>("");
  const [deviceEncryptionKey, setDeviceEncryptionKey] = useState<string>("");
  const [otpToken, setOtpToken] = useState<string>("");

  const [loginResult, setLoginResult] = useState<LoginResult | null>(null);

  const [challengeId, setChallengeId] = useState<string | null>(null);
  const [wallets, setWallets] = useState<Wallet[]>([]);
  const [usdcBalance, setUsdcBalance] = useState<string | null>(null);

  const [status, setStatus] = useState<string>("Ready");
  const [isError, setIsError] = useState<boolean>(false);

  // Initialize SDK on mount
  useEffect(() => {
    let cancelled = false;

    const initSdk = async () => {
      try {
        const onLoginComplete = (error: unknown, result: any) => {
          if (cancelled) return;

          if (error || !result) {
            // Always treat this as a soft failure
            const err = (error || {}) as any;
            const message: string =
              err?.message || "Email authentication failed.";

            console.log("Email auth failed:", {
              code: err?.code,
              message,
            });

            setIsError(true);
            setStatus(message);
            setLoginResult(null);
            return;
          }

          // Success: we get userToken + encryptionKey for challenges
          setLoginResult({
            userToken: result.userToken,
            encryptionKey: result.encryptionKey,
          });
          setIsError(false);
          // Keep this neutral so later wallet-status messages aren't confusing
          setStatus("Email verified. Click Initialize user to continue");
        };

        const sdk = new W3SSdk(
          {
            appSettings: { appId },
          },
          onLoginComplete,
        );

        sdkRef.current = sdk;

        if (!cancelled) {
          setSdkReady(true);
          setIsError(false);
          setStatus("SDK initialized. Ready to request OTP.");
        }
      } catch (err) {
        console.log("Failed to initialize Web SDK:", err);
        if (!cancelled) {
          setIsError(true);
          setStatus("Failed to initialize Web SDK");
        }
      }
    };

    void initSdk();

    return () => {
      cancelled = true;
    };
  }, []);

  // Get / cache deviceId
  useEffect(() => {
    const fetchDeviceId = async () => {
      if (!sdkRef.current) return;

      try {
        const cached =
          typeof window !== "undefined"
            ? window.localStorage.getItem("deviceId")
            : null;

        if (cached) {
          setDeviceId(cached);
          return;
        }

        setDeviceIdLoading(true);
        const id = await sdkRef.current.getDeviceId();
        setDeviceId(id);

        if (typeof window !== "undefined") {
          window.localStorage.setItem("deviceId", id);
        }
      } catch (error) {
        console.log("Failed to get deviceId:", error);
        setIsError(true);
        setStatus("Failed to get deviceId");
      } finally {
        setDeviceIdLoading(false);
      }
    };

    if (sdkReady) {
      void fetchDeviceId();
    }
  }, [sdkReady]);

  // Load USDC balance
  async function loadUsdcBalance(userToken: string, walletId: string) {
    try {
      const response = await fetch("/api/endpoints", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({
          action: "getTokenBalance",
          userToken,
          walletId,
        }),
      });

      const data = await response.json();

      if (!response.ok) {
        console.log("Failed to load USDC balance:", data);
        setIsError(true);
        setStatus("Failed to load USDC balance");
        return null;
      }

      const balances = (data.tokenBalances as any[]) || [];

      const usdcEntry =
        balances.find((t) => {
          const symbol = t.token?.symbol || "";
          const name = t.token?.name || "";
          return symbol.startsWith("USDC") || name.includes("USDC");
        }) ?? null;

      const amount = usdcEntry?.amount ?? "0";
      setUsdcBalance(amount);
      // Note: loadWallets may overwrite this with a more specific status
      setIsError(false);
      setStatus("Wallet details and USDC balance loaded.");
      return amount;
    } catch (err) {
      console.log("Failed to load USDC balance:", err);
      setIsError(true);
      setStatus("Failed to load USDC balance");
      return null;
    }
  }

  // Load wallets for current user
  const loadWallets = async (
    userToken: string,
    options?: { source?: "afterCreate" | "alreadyInitialized" },
  ) => {
    try {
      setIsError(false);
      setStatus("Loading wallet details...");
      setUsdcBalance(null);

      const response = await fetch("/api/endpoints", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({
          action: "listWallets",
          userToken,
        }),
      });

      const data = await response.json();

      if (!response.ok) {
        console.log("List wallets failed:", data);
        setIsError(true);
        setStatus("Failed to load wallet details");
        return;
      }

      const wallets = (data.wallets as Wallet[]) || [];
      setWallets(wallets);

      if (wallets.length > 0) {
        await loadUsdcBalance(userToken, wallets[0].id);

        if (options?.source === "afterCreate") {
          setIsError(false);
          setStatus(
            "Wallet created successfully! 🎉 Wallet details and USDC balance loaded.",
          );
        } else if (options?.source === "alreadyInitialized") {
          setIsError(false);
          setStatus(
            "User already initialized. Wallet details and USDC balance loaded.",
          );
        }
      } else {
        setIsError(false);
        setStatus("Wallet creation in progress. Click Initialize user again to refresh.");
      }
    } catch (err) {
      console.log("Failed to load wallet details:", err);
      setIsError(true);
      setStatus("Failed to load wallet details");
    }
  };

  const handleRequestOtp = async () => {
    if (!email) {
      setIsError(true);
      setStatus("Please enter an email address.");
      return;
    }

    if (!deviceId) {
      setIsError(true);
      setStatus("Missing deviceId. Try again.");
      return;
    }

    // Reset auth + wallet state
    setLoginResult(null);
    setChallengeId(null);
    setWallets([]);
    setUsdcBalance(null);

    try {
      setIsError(false);
      setStatus("Requesting OTP...");

      const response = await fetch("/api/endpoints", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({
          action: "requestEmailOtp",
          deviceId,
          email,
        }),
      });

      const data = await response.json();

      if (!response.ok) {
        console.log("Failed to request OTP:", data);
        setIsError(true);
        setStatus(data.error || data.message || "Failed to request OTP");
        return;
      }

      setDeviceToken(data.deviceToken);
      setDeviceEncryptionKey(data.deviceEncryptionKey);
      setOtpToken(data.otpToken);

      // Give the SDK the session info so verifyOtp() works
      const sdk = sdkRef.current;
      if (sdk) {
        sdk.updateConfigs({
          appSettings: { appId },
          loginConfigs: {
            deviceToken: data.deviceToken,
            deviceEncryptionKey: data.deviceEncryptionKey,
            otpToken: data.otpToken,
            email: { email },
          },
        });
      }

      setIsError(false);
      setStatus(
        "OTP sent! Check your Mailtrap sandbox inbox, then click Verify email OTP.",
      );
    } catch (err) {
      console.log("Error requesting OTP:", err);
      setIsError(true);
      setStatus("Failed to request OTP");
    }
  };

  const handleVerifyOtp = () => {
    const sdk = sdkRef.current;
    if (!sdk) {
      setIsError(true);
      setStatus("SDK not ready");
      return;
    }

    if (!deviceToken || !deviceEncryptionKey || !otpToken) {
      setIsError(true);
      setStatus("Missing OTP session data. Request a new code.");
      return;
    }

    setIsError(false);
    setStatus("Opening OTP verification window...");

    // Opens Circle's hosted OTP UI; on completion, onLoginComplete fires
    sdk.verifyOtp();
  };

  const handleInitializeUser = async () => {
    if (!loginResult?.userToken) {
      setIsError(true);
      setStatus("Missing userToken. Please verify your email first.");
      return;
    }

    try {
      setIsError(false);
      setStatus("Initializing user...");

      const response = await fetch("/api/endpoints", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({
          action: "initializeUser",
          userToken: loginResult.userToken,
        }),
      });

      const data = await response.json();

      if (!response.ok) {
        if (data.code === 155106) {
          await loadWallets(loginResult.userToken, {
            source: "alreadyInitialized",
          });
          setChallengeId(null);
          return;
        }

        const errorMsg = data.code
          ? `[${data.code}] ${data.error || data.message}`
          : data.error || data.message;
        setIsError(true);
        setStatus("Failed to initialize user: " + errorMsg);
        return;
      }

      setChallengeId(data.challengeId);
      setIsError(false);
      setStatus(`User initialized. Click Create wallet to continue.`);
    } catch (err: any) {
      if (err?.code === 155106 && loginResult?.userToken) {
        await loadWallets(loginResult.userToken, {
          source: "alreadyInitialized",
        });
        setChallengeId(null);
        return;
      }

      const errorMsg = err?.code
        ? `[${err.code}] ${err.message}`
        : err?.message || "Unknown error";
      setIsError(true);
      setStatus("Failed to initialize user: " + errorMsg);
    }
  };

  const handleExecuteChallenge = () => {
    const sdk = sdkRef.current;
    if (!sdk) {
      setIsError(true);
      setStatus("SDK not ready");
      return;
    }

    if (!challengeId) {
      setIsError(true);
      setStatus("Missing challengeId. Initialize user first.");
      return;
    }

    if (!loginResult?.userToken || !loginResult?.encryptionKey) {
      setIsError(true);
      setStatus("Missing login credentials. Please verify your email again.");
      return;
    }

    sdk.setAuthentication({
      userToken: loginResult.userToken,
      encryptionKey: loginResult.encryptionKey,
    });

    setIsError(false);
    setStatus("Executing challenge...");

    sdk.execute(challengeId, (error) => {
      const err = (error || {}) as any;

      if (error) {
        console.log("Execute challenge failed:", err);
        setIsError(true);
        setStatus("Failed to execute challenge: " + (err?.message ?? "Unknown error"));
        return;
      }

      setIsError(false);
      setStatus("Challenge executed. Loading wallet details...");

      void (async () => {
        // small delay to give Circle time to index the wallet
        await new Promise((resolve) => setTimeout(resolve, 2000));

        setChallengeId(null);
        await loadWallets(loginResult.userToken, { source: "afterCreate" });
      })().catch((e) => {
        console.log("Post-execute loadWallets failed:", e);
        setIsError(true);
        setStatus("Wallet created, but failed to load wallet details.");
      });
    });
  };

  const primaryWallet = wallets[0];

  return (
    <main>
      <div style={{ width: "50%", margin: "0 auto" }}>
        <h1>Create a user wallet with email OTP</h1>
        <p>Enter the email of the user you want to create a wallet for:</p>

        <div style={{ marginBottom: "12px" }}>
          <label>
            Email address:
            <input
              type="email"
              value={email}
              onChange={(e) => setEmail(e.target.value)}
              style={{ marginLeft: "8px", width: "70%" }}
              placeholder="you@example.com"
            />
          </label>
        </div>

        <div>
          <button
            onClick={handleRequestOtp}
            style={{ margin: "6px" }}
            disabled={!sdkReady || !deviceId || deviceIdLoading || !email}
          >
            1. Send email OTP
          </button>
          <br />
          <button
            onClick={handleVerifyOtp}
            style={{ margin: "6px" }}
            disabled={
              !sdkReady || !deviceToken || !deviceEncryptionKey || !otpToken || !!loginResult
            }
          >
            2. Verify email OTP
          </button>
          <br />
          <button
            onClick={handleInitializeUser}
            style={{ margin: "6px" }}
            disabled={!loginResult || !!challengeId || wallets.length > 0}
          >
            3. Initialize user (get challenge)
          </button>
          <br />
          <button
            onClick={handleExecuteChallenge}
            style={{ margin: "6px" }}
            disabled={!challengeId || wallets.length > 0}
          >
            4. Create wallet (execute challenge)
          </button>
        </div>

        <p>
          <strong>Status:</strong>{" "}
          <span style={{ color: isError ? "red" : "black" }}>{status}</span>
        </p>

        {primaryWallet && (
          <div style={{ marginTop: "12px" }}>
            <h2>Wallet details</h2>
            <p>
              <strong>Address:</strong> {primaryWallet.address}
            </p>
            <p>
              <strong>Blockchain:</strong> {primaryWallet.blockchain}
            </p>
            {usdcBalance !== null && (
              <p>
                <strong>USDC balance:</strong> {usdcBalance}
              </p>
            )}
          </div>
        )}

        <pre
          style={{
            whiteSpace: "pre-wrap",
            wordBreak: "break-all",
            lineHeight: "1.8",
            marginTop: "16px",
          }}
        >
          {JSON.stringify(
            {
              deviceId,
              email,
              deviceToken,
              deviceEncryptionKey,
              otpToken,
              userToken: loginResult?.userToken,
              encryptionKey: loginResult?.encryptionKey,
              challengeId,
              wallets,
              usdcBalance,
            },
            null,
            2,
          )}
        </pre>
      </div>
    </main>
  );
}
This page renders the UI and implements all browser-side logic for the email-OTP authentication and wallet creation flow. It initializes the Web SDK, sends and verifies OTP codes, manages short-lived state during the login process, and coordinates the sequence of actions required to create and display the user’s wallet.

Step 4. Run the app flow

  1. Start the dev server:
npm run dev
  1. Open http://localhost:3000 in your browser to view the app.
  1. Complete the email OTP authentication and wallet creation flow:
    1. Enter an email address: Choose an email address for the user who will authenticate. This email address is used only to receive a one-time passcode (OTP) during sign-in.
    2. Click Send email OTP: The Web SDK generates a unique deviceId, which identifies the user’s browser. Your backend sends the deviceId and email address to Circle, which emails an OTP code and returns temporary verification tokens (deviceToken, deviceEncryptionKey, otpToken) used by the Web SDK to verify the OTP.
    3. Open your Mailtrap Sandbox → Inbox to view the OTP email sent by Circle. When using a Mailtrap Email Sandbox, OTP emails are received in the Mailtrap UI by default and are not delivered to the user’s inbox, unless email forwarding is configured.
      In a production app, you should configure your SMTP provider to deliver OTP codes directly to users’ email inboxes.
    4. Click Verify email OTP: Circle opens a hosted OTP verification window. The user enters the code they received. The Web SDK verifies the OTP with Circle, which then returns a userToken and encryptionKey. Together, they enable an authenticated Circle user session.
    5. Click Initialize user: Your backend initializes the user using the userToken. If the user hasn’t created a wallet yet, Circle returns a challengeId to create one. If the user is already initialized, the app loads the existing wallet instead.
    6. Click Create wallet: The Web SDK executes the challenge using the challengeId. The user approves the action, and Circle creates the wallet.
  2. Once the flow completes:
    • The app displays the wallet’s address, blockchain, and USDC balance.
    • You can verify the user was created in the Circle Dev Console:
      Wallets → User Controlled → Users.

Step 5. Fund the wallet

In this step, you fund the new wallet manually using the Circle Faucet and confirm the updated balance in the app.
  1. Copy the wallet address (0x...) from the web app UI.
  2. Visit the official Circle Faucet.
  3. Select Arc Testnet as the blockchain network.
  4. Paste the wallet address in the Send to field.
  5. Click Send USDC.
  6. Return to the app and walk through the flow again.
    Note: Use the same email address to show the same wallet.
  7. The app will display the updated USDC balance.
In this step, you’re acting as the end user to fund your user-controlled wallet for testing. In production, app developers don’t control user wallets or private keys. Instead, users typically fund wallets themselves, but apps may also fund using faucets or airdrops without requiring wallet access.