Skip to main content
This guide supports both React and React Native.

Overview

One of the biggest barriers to mainstream crypto adoption is the complexity of wallet addresses and the requirement that both parties must have wallets. This recipe shows you how to build a user-friendly USDC transfer system where users can send stablecoins to friends using familiar identifiers like email addresses or phone numbers—even if the recipient hasn’t signed up yet. Using Dynamic’s pregeneration feature, you can create wallets for recipients automatically when they’re sent funds. When recipients later sign up with that email or phone number, they’ll automatically receive their wallet with the funds already in it.

What We’re Building

A React/React Native application that allows users to:
  • Send USDC to friends by entering their email address or phone number
  • Automatically create wallets for recipients who don’t have one yet (pregeneration)
  • Transfer USDC seamlessly without exposing wallet addresses to end users
  • Enable recipients to claim their wallet and funds when they sign up later

Architecture

The solution uses a secure backend-to-backend pattern:
  1. Backend API Endpoint: A secure backend endpoint queries Dynamic’s API with filters to find users by their email/phone verified credentials. The Dynamic API token is never exposed to the client.
  2. Frontend Lookup: The React/React Native app calls your backend API endpoint (not Dynamic’s API directly)
  3. Wallet Resolution: The backend extracts wallet addresses from the found user’s embedded wallets
  4. Transfer Component: A React/React Native component that looks up recipients via your backend API and executes USDC transfers

Prerequisites

  • Sender must be a Dynamic user with a wallet linked (either embedded or external)
  • Recipient can be anyone with an email address or phone number (they don’t need to have signed up yet)
  • Backend server with Dynamic API token (the token must never be exposed to the client)
  • Embedded wallets enabled in your Dynamic environment for pregeneration support

Step 1: Get Your Dynamic API Credentials

  1. Navigate to Dynamic Dashboard
  2. Copy your Environment ID
  3. Create a new API token
Environment ID and API Token

Step 2: Create User Lookup API Endpoint (existing users only)

Security: The Dynamic API call must happen on your backend server, never from the client. The API token must be kept secure and never exposed to the frontend.
First create a lookup endpoint that only searches for existing users by email or phone. We’ll rely on Dynamic’s published User type instead of defining our own shape.
// pages/api/users/lookup.ts (Next.js API route)
// or app/api/users/lookup/route.ts (Next.js App Router)
import type { NextApiRequest, NextApiResponse } from "next";
import type { User as DynamicUser } from "@dynamic-labs/sdk-api";

interface UserLookupResponse {
  walletAddress: string;
  email?: string;
  phone?: string;
  userId?: string;
  isPregenerated: false;
}

export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse<UserLookupResponse | { error: string }>
) {
  if (req.method !== "GET") {
    return res.status(405).json({ error: "Method not allowed" });
  }

  const { email, phone } = req.query;
  const environmentId = process.env.DYNAMIC_ENVIRONMENT_ID;
  const apiToken = process.env.DYNAMIC_AUTH_TOKEN;

  if (!environmentId || !apiToken) {
    return res.status(500).json({ error: "Server configuration error" });
  }

  if (!email && !phone) {
    return res.status(400).json({ error: "Email or phone is required" });
  }

  try {
    const queryParams = new URLSearchParams();

    if (email) {
      queryParams.set("filter[filterColumn]", "email");
      queryParams.set("filter[filterValue]", email as string);
    } else if (phone) {
      queryParams.set("filter[filterColumn]", "phoneNumber");
      queryParams.set("filter[filterValue]", phone as string);
    }

    const response = await fetch(
      `https://app.dynamicauth.com/api/v0/environments/${environmentId}/users?${queryParams.toString()}`,
      {
        method: "GET",
        headers: {
          Authorization: `Bearer ${apiToken}`,
          "Content-Type": "application/json",
        },
      }
    );

    if (!response.ok) {
      throw new Error(`Dynamic API error: ${response.statusText}`);
    }

    const data = await response.json();
    const users: DynamicUser[] = data.users || [];
    const foundUser = users[0];

    if (!foundUser) {
      return res.status(404).json({ error: "User not found" });
    }

    const evmWallet = foundUser.wallets?.find(
      (wallet) => wallet.chain === "eip155"
    );

    if (!evmWallet?.address) {
      return res.status(404).json({ error: "User has no EVM wallet" });
    }

    const emailCredential = foundUser.verifiedCredentials?.find(
      (vc) => vc.type === "email"
    );
    const phoneCredential = foundUser.verifiedCredentials?.find(
      (vc) => vc.type === "phone"
    );

    return res.status(200).json({
      walletAddress: evmWallet.address,
      email: emailCredential?.email || foundUser.email,
      phone: phoneCredential?.phone || foundUser.phone,
      userId: foundUser.userId,
      isPregenerated: false,
    });
  } catch (error: any) {
    console.error("Error looking up user:", error);
    res.status(500).json({ error: error.message || "Failed to lookup user" });
  }
}

Environment Variables

Set up your environment variables:
# .env.local (Next.js)
DYNAMIC_ENVIRONMENT_ID=your_environment_id_here
DYNAMIC_AUTH_TOKEN=your_api_token_here

Step 3: Create Wallet Pregeneration Endpoint

Keep pregeneration in its own endpoint so it is clear, auditable, and can be called independently (for example, to pre-warm wallets in batches).
// pages/api/users/pregen.ts (Next.js API route)
// or app/api/users/pregen/route.ts (Next.js App Router)
import type { NextApiRequest, NextApiResponse } from "next";

interface PregenerationResponse {
  walletAddress: string;
  email?: string;
  phone?: string;
  userId?: string;
  isPregenerated: true;
}

export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse<PregenerationResponse | { error: string }>
) {
  if (req.method !== "POST") {
    return res.status(405).json({ error: "Method not allowed" });
  }

  const { email, phone } = req.body || {};
  const environmentId = process.env.DYNAMIC_ENVIRONMENT_ID;
  const apiToken = process.env.DYNAMIC_AUTH_TOKEN;

  if (!environmentId || !apiToken) {
    return res.status(500).json({ error: "Server configuration error" });
  }

  if (!email && !phone) {
    return res.status(400).json({ error: "Email or phone is required" });
  }

  try {
    const identifier = email || phone;
    const identifierType = email ? "email" : "phone";

    const pregenResponse = await fetch(
      `https://app.dynamicauth.com/api/v0/environments/${environmentId}/waas/create`,
      {
        method: "POST",
        headers: {
          Authorization: `Bearer ${apiToken}`,
          "Content-Type": "application/json",
        },
        body: JSON.stringify({
          identifier: identifier as string,
          type: identifierType,
          chains: ["EVM"],
        }),
      }
    );

    if (!pregenResponse.ok) {
      const errorData = await pregenResponse.text();
      throw new Error(`Failed to pregenerate wallet: ${errorData}`);
    }

    const pregenWallet = await pregenResponse.json();
    const evmWallet = pregenWallet.wallets?.find(
      (wallet: any) => wallet.chain === "eip155"
    );

    if (!evmWallet?.address) {
      throw new Error("Pregenerated wallet missing EVM address");
    }

    return res.status(200).json({
      walletAddress: evmWallet.address,
      email: email as string | undefined,
      phone: phone as string | undefined,
      userId: undefined, // No user ID until the user signs up
      isPregenerated: true,
    });
  } catch (error: any) {
    console.error("Error pregenerating wallet:", error);
    res
      .status(500)
      .json({ error: error.message || "Failed to pregenerate wallet" });
  }
}

Step 4: Create User Lookup Hook (React Query)

Create a custom hook that uses React Query to first try lookup, then fall back to pregeneration. Make sure your app is wrapped in a QueryClientProvider.
React
// hooks/useRecipientLookup.ts
import { useState } from "react";
import { useMutation } from "@tanstack/react-query";

type IdentifierType = "email" | "phone";

interface RecipientResult {
  walletAddress: string;
  email?: string;
  phone?: string;
  userId?: string;
  isPregenerated: boolean;
  found: boolean;
}

interface ResolveInput {
  identifier: string;
  identifierType: IdentifierType;
}

const ensureResponse = async (response: Response) => {
  if (response.ok) return response.json();
  const errorData = await response.json().catch(() => ({}));
  const err = new Error(errorData.error || response.statusText);
  (err as any).status = response.status;
  throw err;
};

export const useRecipientLookup = () => {
  const [recipient, setRecipient] = useState<RecipientResult | null>(null);

  const lookupMutation = useMutation({
    mutationFn: async ({ identifier, identifierType }: ResolveInput) => {
      const searchParam =
        identifierType === "email"
          ? `email=${encodeURIComponent(identifier)}`
          : `phone=${encodeURIComponent(identifier)}`;
      const res = await fetch(`/api/users/lookup?${searchParam}`);
      const data = await ensureResponse(res);
      return { ...data, found: true };
    },
  });

  const pregenerateMutation = useMutation({
    mutationFn: async ({ identifier, identifierType }: ResolveInput) => {
      const res = await fetch("/api/users/pregen", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(
          identifierType === "email"
            ? { email: identifier }
            : { phone: identifier }
        ),
      });
      const data = await ensureResponse(res);
      return { ...data, found: true };
    },
  });

  const resolveRecipient = async (payload: ResolveInput) => {
    setRecipient(null);
    try {
      const data = await lookupMutation.mutateAsync(payload);
      setRecipient(data);
      return data;
    } catch (err: any) {
      if (err?.status === 404) {
        const data = await pregenerateMutation.mutateAsync(payload);
        setRecipient(data);
        return data;
      }
      throw err;
    }
  };

  return {
    resolveRecipient,
    recipient,
    isLoading: lookupMutation.isPending || pregenerateMutation.isPending,
    error:
      (lookupMutation.error as Error | undefined)?.message ||
      (pregenerateMutation.error as Error | undefined)?.message ||
      null,
    clearRecipient: () => setRecipient(null),
  };
};

Step 5: Create Transfer Component

Now create the main transfer component that allows users to send USDC by email or phone:
React
// components/SendUSDCByIdentifier.tsx
import { useState } from "react";
import { useDynamicContext } from "@dynamic-labs/sdk-react-core";
import { isEthereumWallet } from "@dynamic-labs/ethereum";
import { parseUnits, erc20Abi } from "viem";
import { useRecipientLookup } from "../hooks/useRecipientLookup";

const SendUSDCByIdentifier = () => {
  const { primaryWallet } = useDynamicContext();
  const {
    resolveRecipient,
    recipient,
    isLoading: isLookingUp,
    error: lookupError,
    clearRecipient,
  } = useRecipientLookup();

  const [identifier, setIdentifier] = useState("");
  const [identifierType, setIdentifierType] = useState<"email" | "phone">(
    "email"
  );
  const [amount, setAmount] = useState("");
  const [isSending, setIsSending] = useState(false);
  const [txHash, setTxHash] = useState<string | null>(null);
  const [error, setError] = useState<string | null>(null);

  const handleLookup = async () => {
    if (!identifier.trim()) {
      setError("Please enter an email or phone number");
      return;
    }

    setError(null);
    setTxHash(null);

    if (identifierType === "email") {
      // Basic email validation
      const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
      if (!emailRegex.test(identifier)) {
        setError("Please enter a valid email address");
        return;
      }
    } else {
      // Basic phone validation (adjust regex for your needs)
      const phoneRegex = /^\+?[1-9]\d{1,14}$/;
      if (!phoneRegex.test(identifier.replace(/\s/g, ""))) {
        setError("Please enter a valid phone number");
        return;
      }
    }

    try {
      await resolveRecipient({ identifier, identifierType });
    } catch (err: any) {
      setError(err.message || "Unable to find or create wallet for this user");
    }
  };

  const handleSend = async () => {
    if (!primaryWallet || !isEthereumWallet(primaryWallet)) {
      setError("Wallet not connected or not EVM compatible");
      return;
    }

    if (!recipient || !recipient.found) {
      setError("Please find a recipient first");
      return;
    }

    if (!amount || parseFloat(amount) <= 0) {
      setError("Please enter a valid amount");
      return;
    }

    setIsSending(true);
    setError(null);

    try {
      const walletClient = await primaryWallet.getWalletClient();
      const publicClient = await primaryWallet.getPublicClient();

      // USDC contract address (replace with your network's USDC address)
      // This example uses Ethereum mainnet - adjust for your network
      const usdcAddress = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48";

      // Convert amount to USDC units (6 decimals)
      const amountInUnits = parseUnits(amount, 6);

      // Send USDC transfer
      const hash = await walletClient.writeContract({
        address: usdcAddress as `0x${string}`,
        abi: erc20Abi,
        functionName: "transfer",
        args: [recipient.walletAddress as `0x${string}`, amountInUnits],
      });

      setTxHash(hash);

      // Wait for transaction receipt
      const receipt = await publicClient.waitForTransactionReceipt({ hash });
      console.log("USDC transfer successful:", receipt);

      // Reset form
      setIdentifier("");
      setAmount("");
      clearRecipient();
    } catch (err: any) {
      console.error("Failed to send USDC:", err);
      if (err.message.includes("insufficient funds")) {
        setError("Insufficient USDC balance");
      } else if (err.message.includes("user rejected")) {
        setError("Transaction was cancelled");
      } else {
        setError(err.message || "Failed to send USDC");
      }
    } finally {
      setIsSending(false);
    }
  };

  return (
    <div style={{ maxWidth: "500px", margin: "0 auto", padding: "20px" }}>
      <h2>Send USDC</h2>

      <div style={{ marginBottom: "20px" }}>
        <label style={{ display: "block", marginBottom: "8px" }}>
          Send to:
        </label>
        <div style={{ display: "flex", gap: "10px", marginBottom: "10px" }}>
          <select
            value={identifierType}
            onChange={(e) => {
              setIdentifierType(e.target.value as "email" | "phone");
              setIdentifier("");
              clearRecipient();
            }}
            style={{ padding: "8px" }}
          >
            <option value="email">Email</option>
            <option value="phone">Phone</option>
          </select>
          <input
            type={identifierType === "email" ? "email" : "tel"}
            value={identifier}
            onChange={(e) => {
              setIdentifier(e.target.value);
              clearRecipient();
            }}
            placeholder={
              identifierType === "email"
                ? "[email protected]"
                : "+1234567890"
            }
            style={{ flex: 1, padding: "8px" }}
            onKeyPress={(e) => {
              if (e.key === "Enter") {
                handleLookup();
              }
            }}
          />
          <button
            onClick={handleLookup}
            disabled={isLookingUp || !identifier.trim()}
            style={{ padding: "8px 16px" }}
          >
            {isLookingUp ? "Looking up..." : "Find"}
          </button>
        </div>

        {recipient && recipient.found && (
          <div
            style={{
              padding: "12px",
              backgroundColor: recipient.isPregenerated ? "#fff4e6" : "#f0f9ff",
              borderRadius: "8px",
              marginTop: "10px",
            }}
          >
            <p style={{ margin: 0, fontWeight: "bold" }}>
              {recipient.isPregenerated
                ? "✓ Wallet created for:"
                : "✓ User found:"}{" "}
              {recipient.email || recipient.phone}
            </p>
            <p style={{ margin: "4px 0 0 0", fontSize: "12px", color: "#666" }}>
              Wallet: {recipient.walletAddress.substring(0, 8)}...
              {recipient.walletAddress.substring(
                recipient.walletAddress.length - 6
              )}
            </p>
            {recipient.isPregenerated && (
              <p
                style={{
                  margin: "4px 0 0 0",
                  fontSize: "11px",
                  color: "#856404",
                  fontStyle: "italic",
                }}
              >
                They'll receive this wallet when they sign up with this{" "}
                {recipient.email ? "email" : "phone number"}
              </p>
            )}
          </div>
        )}
      </div>

      {recipient && recipient.found && (
        <>
          <div style={{ marginBottom: "20px" }}>
            <label style={{ display: "block", marginBottom: "8px" }}>
              Amount (USDC):
            </label>
            <input
              type="number"
              value={amount}
              onChange={(e) => setAmount(e.target.value)}
              placeholder="0.00"
              min="0"
              step="0.01"
              style={{ width: "100%", padding: "8px" }}
            />
          </div>

          <button
            onClick={handleSend}
            disabled={isSending || !amount || parseFloat(amount) <= 0}
            style={{
              width: "100%",
              padding: "12px",
              backgroundColor: "#4779FE",
              color: "white",
              border: "none",
              borderRadius: "8px",
              fontSize: "16px",
              cursor: isSending ? "not-allowed" : "pointer",
            }}
          >
            {isSending ? "Sending..." : `Send ${amount || "0"} USDC`}
          </button>
        </>
      )}

      {(error || lookupError) && (
        <div
          style={{
            marginTop: "16px",
            padding: "12px",
            backgroundColor: "#fee",
            color: "#c00",
            borderRadius: "8px",
          }}
        >
          {error || lookupError}
        </div>
      )}

      {txHash && (
        <div
          style={{
            marginTop: "16px",
            padding: "12px",
            backgroundColor: "#efe",
            color: "#060",
            borderRadius: "8px",
          }}
        >
          <p style={{ margin: 0, fontWeight: "bold" }}>✓ Transaction sent!</p>
          <p style={{ margin: "4px 0 0 0", fontSize: "12px" }}>
            Hash: {txHash.substring(0, 16)}...
            {txHash.substring(txHash.length - 8)}
          </p>
          <a
            href={`https://etherscan.io/tx/${txHash}`}
            target="_blank"
            rel="noopener noreferrer"
            style={{ fontSize: "12px", color: "#4779FE" }}
          >
            View on Etherscan
          </a>
        </div>
      )}
    </div>
  );
};

export default SendUSDCByIdentifier;

How Recipients Claim Their Wallets

When you send USDC to a friend who doesn’t have a wallet yet:
  1. Pregeneration: A wallet is automatically created for them with the email/phone you provided
  2. Funds Arrive: The USDC is sent to that wallet address immediately
  3. Sign Up: When your friend signs up with Dynamic using the same email or phone number
  4. Automatic Claim: They automatically receive their pregenerated wallet with the funds already in it
No additional steps are required - the wallet claiming happens automatically during authentication. For more details, see the pregeneration guide.

Important Considerations

Security

  1. API Token Protection: Critical: Never expose your Dynamic API token in client-side code. All Dynamic API calls must happen on your backend server. The frontend should only call your backend API endpoints.
  2. Rate Limiting: Implement rate limiting on lookup endpoints to prevent abuse
  3. Authentication: Consider requiring authentication for lookup endpoints to prevent unauthorized access
  4. Validation: Always validate email/phone formats and wallet addresses on both frontend and backend
  5. Backend Security: Keep your backend API token secure using environment variables and never commit it to version control

Performance

  1. Rate limits (Dynamic): /environments/:environmentId/users follows developer limits of 1500 requests/min per IP and 3000 requests/min per environment; /environments/:environmentId/users/embeddedWallets (pregeneration) is limited to 300 requests/min per IP. See rate limits.
  2. Caching: Cache user lookups to reduce API calls and stay under limits
  3. Filtering: The API supports filtering - check the API reference for available filter parameters

User Experience

  1. Error Handling: Provide clear error messages for common issues
  2. Loading States: Show loading indicators during lookup and transfer
  3. Confirmation: Always show the recipient’s identifier before sending
  4. Transaction Status: Display transaction hashes and links to block explorers

Best Practices

  1. Error Handling: Handle cases where users don’t have EVM wallets or pregeneration fails
  2. Multiple Wallets: If a user has multiple EVM wallets, consider letting them choose or use the primary wallet
  3. Pregeneration: Always pregenerate wallets for new recipients so they can receive funds immediately
  4. Notifications: Consider sending email/SMS notifications to recipients when they receive USDC (especially for pregenerated wallets)
  5. Caching: Cache user lookups to improve performance and reduce API calls
  6. Network: Remember that USDC contract addresses differ by network. Update the usdcAddress in your component based on the network.
  7. User Communication: Inform recipients that they’ll need to sign up with the same email/phone to claim their funds