Skip to main content
In this quickstart, you build a web app that:
  • Lets users create or access a user-owned wallet using an app-defined user identifier
  • Prompts users to set or confirm a PIN to authorize sensitive wallet actions
  • Displays the user’s wallet address and the USDC balance it holds

Prerequisites

Before you begin, ensure you have:

Step 1. Get your Circle App ID

In this step, you obtain the App ID that identifies your user-controlled wallet configuration in the Circle Developer Console, so it can manage the User IDs created by your app.
  1. Log in to the Circle Developer Console.
  2. Go to Wallets → User Controlled → Configurator, and copy your App ID. You need it for the next step.
If you know your Circle API key, you can get your App ID directly from the command-line by querying the Get configuration for entity endpoint:
curl -s https://api.circle.com/v1/w3s/config/entity \
  -H "Authorization: Bearer <YOUR_CIRCLE_API_KEY>" \
  -H "Accept: application/json"

Step 2. Create the web application

In this step, you create a Next.js app that lets users set an authorization PIN and use it to create a user-controlled wallet.

2.1. Create the Next.js project

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

2.2. Install dependencies

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

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

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

2.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 "createUser": {
        const { userId } = params;

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

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

        const data = await response.json();

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

        // Returns: { id, createDate, pinStatus, status, ... }
        return NextResponse.json(data.data, { status: 200 });
      }

      case "getUserToken": {
        const { userId } = params;

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

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

        const data = await response.json();

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

        // Returns: { userToken, encryptionKey }
        return NextResponse.json(data.data, { status: 200 });
      }

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

        // Build request body
        const requestBody: any = {
          idempotencyKey: crypto.randomUUID(),
        };

        // Add optional parameters with defaults
        if (accountType) requestBody.accountType = accountType;
        if (blockchains) requestBody.blockchains = blockchains;

        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(requestBody),
          },
        );

        const data = await response.json();

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

        // Returns: { 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
createUsercalls POST /v1/w3s/users to create (or retrieve) a Circle user using an application-defined userId.
getUserTokencalls POST /v1/w3s/users/token to create a short-lived user session, returning a userToken and encryptionKey required by the Web SDK to run PIN and wallet challenges.
initializeUsercalls POST /v1/w3s/user/initialize to initialize the user and return a challengeId required for wallet creation.
listWalletscalls GET /v1/w3s/wallets to retrieve the wallets associated with the user.
getTokenBalancecalls GET /v1/w3s/wallets//balances to retrieve digital asset balances for a 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 3 below.

​2.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;
const ACCOUNT_TYPE = "SCA";
const PRIMARY_WALLET_BLOCKCHAIN = "ARC-TESTNET";

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 [userId, setUserId] = 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 sdk = new W3SSdk({
          appSettings: { appId },
        });

        sdkRef.current = sdk;

        if (!cancelled) {
          setSdkReady(true);
          setIsError(false);
          setStatus("SDK initialized. Ready to create user.");
        }
      } 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;
        }

        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");
      }
    };

    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 handleCreateUser = async () => {
    if (!userId) {
      setIsError(true);
      setStatus("Please enter a user ID.");
      return;
    }

    if (userId.length < 5) {
      setIsError(true);
      setStatus("User ID must be at least 5 characters.");
      return;
    }

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

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

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

      const data = await response.json();

      if (!response.ok) {
        console.log("Failed to create user:", data);
        setIsError(data.code === 155106);
        setStatus(data.error || data.message || "Failed to create user");
        return;
      }

      setIsError(false);
      setStatus("User created successfully! Click Get User Token to continue.");
    } catch (err) {
      console.log("Error creating user:", err);
      setIsError(true);
      setStatus("Failed to create user");
    }
  };

  const handleGetUserToken = async () => {
    if (!userId) {
      setIsError(true);
      setStatus("Please enter a user ID.");
      return;
    }

    if (userId.length < 5) {
      setIsError(true);
      setStatus("User ID must be at least 5 characters.");
      return;
    }

    try {
      setIsError(false);
      setStatus("Getting user token...");

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

      const data = await response.json();

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

      // Set loginResult with userToken and encryptionKey from response
      setLoginResult({
        userToken: data.userToken,
        encryptionKey: data.encryptionKey,
      });

      setIsError(false);
      setStatus("User token retrieved successfully! Click Initialize user to continue.");
    } catch (err) {
      console.log("Error getting user token:", err);
      setIsError(true);
      setStatus("Failed to get user token");
    }
  };

  const handleInitializeUser = async () => {
    if (!loginResult?.userToken) {
      setIsError(true);
      setStatus("Missing userToken. Please get user token 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,
          accountType: ACCOUNT_TYPE,
          blockchains: [PRIMARY_WALLET_BLOCKCHAIN],
        }),
      });

      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 = async () => {
    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 get user token again.");
      return;
    }

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

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

      await sdk.execute(challengeId, (error, result) => {
        if (error) {
          console.log("Execute challenge failed:", error);
          setIsError(true);
          setStatus(
            "Failed to execute challenge: " +
              ((error as any)?.message ?? "Unknown error"),
          );
          return;
        }

        console.log("Challenge executed successfully:", result);
        setChallengeId(null);

        // Small delay to give Circle time to index the wallet
        setTimeout(async () => {
          if (loginResult?.userToken) {
            await loadWallets(loginResult.userToken, { source: "afterCreate" });
          }
        }, 2000);
      });
    } catch (err) {
      console.log("Execute challenge error:", err);
      setIsError(true);
      setStatus(
        "Failed to execute challenge: " +
          ((err as any)?.message ?? "Unknown error"),
      );
    }
  };

  const primaryWallet = wallets[0];

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

        <div style={{ marginBottom: "12px" }}>
          <label>
            User ID:
            <input
              type="text"
              value={userId}
              onChange={(e) => setUserId(e.target.value)}
              style={{ marginLeft: "8px", width: "70%" }}
              placeholder="Enter user ID (min 5 chars)"
              minLength={5}
            />
          </label>
        </div>

        <div>
          <button
            onClick={handleCreateUser}
            style={{ margin: "6px" }}
            disabled={!userId || userId.length < 5}
          >
            1. Create User
          </button>
          <br />
          <button
            onClick={handleGetUserToken}
            style={{ margin: "6px" }}
            disabled={!userId || userId.length < 5 || !!loginResult}
          >
            2. Get User Token
          </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,
              userId,
              userToken: loginResult?.userToken,
              encryptionKey: loginResult?.encryptionKey,
              challengeId,
              wallets,
              usdcBalance,
            },
            null,
            2,
          )}
        </pre>
      </div>
    </main>
  );
}
This page handles the full client-side logic for the PIN-based wallet creation flow. It initializes the Web SDK, creates a new user with a userId, retrieves a userToken, and initializes the user’s wallet on a specified blockchain. The returned challengeId is executed using the SDK, prompting the user to authorize the action with a PIN. After wallet creation, the app loads the wallet details and USDC balance.

Step 3. 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 PIN setup and wallet creation flow:
    1. Enter a User ID: Provide a unique identifier for the user (for example, a username or email address). In this PIN-only flow, the app receives the identifier directly from the user but doesn’t perform authentication to verify ownership.
      A PIN by itself does not verify user identity. This quickstart focuses on PIN-based authorization only. In production, apps should authenticate users first (for example, with social login or email OTP) to establish a Circle-authenticated user session. PINs can then serve as a layer to authorize sensitive wallet actions.
    2. Click Create User: Your backend creates a Circle user record from the provided userId. Circle associates it with your Console account and user-controlled wallet configuration based on your API key.
    3. Click Get User Token: Your backend requests a short-lived userToken and encryptionKey. The Web SDK uses these credentials to authenticate the user session with Circle and submit subsequent user-scoped challenges.
    4. Click Initialize user (get challenge): Your backend initializes the user using the userToken. If the user hasn’t created a wallet yet, Circle returns a challengeId required to create one. If the user is already initialized, the app loads the existing wallet instead.
    5. Click Create wallet (execute challenge): The Web SDK executes the challenge using the challengeId. Because wallet creation is a sensitive action, Circle opens a hosted UI where the user sets their authorization PIN and security questions. After the user approves the request, 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 4. 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.