Skip to main content
This integration requires access to both Rain’s card issuance API and Crossmint’s wallet services.

Introduction

This guide shows you how to integrate Rain’s card issuance platform with Crossmint wallets to build a complete crypto-to-credit card application. You’ll learn how to create an app where users can sign up, issue a Visa card, and fund them directly from their wallets.
Check out our live demo to see the integration in action before building your own.
What you’ll build:
  • Virtual Visa card issuance
  • Crypto-to-card funding with RUSD tokens
  • Real-time balance and transaction monitoring
Tech Stack:

Prerequisites

Rain API Key

Get your Rain Staging API key with card issuance permissions

Crossmint API Key

Get your Crossmint Staging API key with wallet and transaction scopes

Next.js Project

Set up a Next.js project with App Router enabled for server actions: npx create-next-app@latest my-rain-crossmint-app

Install Dependencies

# Using npm
npm install @crossmint/client-sdk-react-ui viem

# Using pnpm
pnpm add @crossmint/client-sdk-react-ui viem

# Using yarn
yarn add @crossmint/client-sdk-react-ui viem

Integration Steps

1

Set up Crossmint Authentication and Wallet Providers

Set up the complete Crossmint provider stack with authentication and wallet functionality. This enables users to sign in and automatically creates wallets for them.
app/providers.tsx
"use client";

import {
  CrossmintProvider,
  CrossmintAuthProvider,
  CrossmintWalletProvider,
} from "@crossmint/client-sdk-react-ui";

export function Providers({ children }: { children: React.ReactNode }) {
  return (
    <CrossmintProvider apiKey={process.env.NEXT_PUBLIC_CROSSMINT_API_KEY!}>
      <CrossmintAuthProvider>
        <CrossmintWalletProvider
          createOnLogin={{
            chain: "base-sepolia", // Using Base Sepolia for this demo
            signer: {
              type: "email",
            },
          }}
        >
          {children}
        </CrossmintWalletProvider>
      </CrossmintAuthProvider>
    </CrossmintProvider>
  );
}
app/layout.tsx
import { Providers } from "./providers";

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>
        <Providers>{children}</Providers>
      </body>
    </html>
  );
}
2

Add Environment Configuration

Set up your environment variables for both Rain and Crossmint APIs.
.env
# Crossmint Configuration
NEXT_PUBLIC_CROSSMINT_API_KEY=your_crossmint_api_key
NEXT_PUBLIC_CHAIN=base-sepolia

# Rain Configuration
RAIN_API_KEY=your_rain_api_key
3

Build the Rain Integration Layer

Create Next.js server action functions to handle Rain API calls securely on the server side. This keeps your Rain API key secure and provides a clean interface for your React components.
actions/rain.ts
"use server";

const RAIN_API_URL = "https://api-dev.raincards.xyz/v1";

/**
* Creates a new Rain user application with KYC data
* This is the first step - submits user information to Rain for account creation
* Using "approved" as lastName will skip KYC verification in sandbox mode
*/
export async function createRainUserApplication(params: {
  firstName: string;
  lastName: string;
  email: string;
  walletAddress: string;
  // ... other required fields
}) {
  const response = await fetch(`${RAIN_API_URL}/issuing/applications/user`, {
    method: "POST",
    headers: {
      "Api-Key": process.env.RAIN_API_KEY!, // This variable should be defined in your .env file
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      ...params,
      // Demo data for quick testing
      birthDate: "1990-01-01",
      nationalId: "123456789",
      countryOfIssue: "US",
      address: {
        line1: "123 Test Street",
        city: "San Francisco",
        region: "CA",
        postalCode: "94105",
        countryCode: "US",
      },
      ipAddress: "127.0.0.1",
      phoneCountryCode: "1",
      phoneNumber: "5551234567",
      annualSalary: "75000",
      accountPurpose: "personal",
      expectedMonthlyVolume: "2000",
      isTermsOfServiceAccepted: true,
    }),
  });

  if (!response.ok) {
    const error = await response.json();
    throw new Error(`Rain application failed: ${error.message}`);
  }

  const result = await response.json();
  return { userId: result.id, status: result.applicationStatus };
}

/**
* Creates a smart contract for the approved Rain user
* Rain manages the smart contract deployment and operations for collateral handling
* This contract will hold the user's crypto collateral (RUSD tokens)
*/
export async function createRainUserContract(userId: string, chainId: number) {
  await fetch(`${RAIN_API_URL}/issuing/users/${userId}/contracts`, {
    method: "POST",
    headers: {
      "Api-Key": process.env.RAIN_API_KEY!,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ chainId }),
  });
}

/**
* Issues a virtual Visa credit card for the Rain user
* The card will be linked to their smart contract for collateral-backed spending
* Card starts active and ready to be funded with crypto collateral
*/
export async function issueRainCard(userId: string, displayName: string) {
  const response = await fetch(
    `${RAIN_API_URL}/issuing/users/${userId}/cards`,
    {
      method: "POST",
      headers: {
        "Api-Key": process.env.RAIN_API_KEY!,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        type: "virtual",
        limit: { frequency: "allTime", amount: 1000 },
        displayName,
        status: "active",
      }),
    }
  );

  return await response.json();
}

/**
* Gets user's existing Rain cards
* Returns array of cards with basic info like status and last four digits
*/
export async function getRainUserCards(userId: string) {
  const response = await fetch(
    `${RAIN_API_URL}/issuing/cards?userId=${userId}&limit=20`,
    {
      headers: { "Api-Key": process.env.RAIN_API_KEY! },
    }
  );
  return await response.json();
}

/**
* Gets Rain user smart contracts with retry logic
* Returns Base Sepolia contract with RUSD token info
*/
export async function getRainUserContracts(userId: string) {
  const BASE_SEPOLIA_CHAIN_ID = 84532;
  const RUSD_CONTRACT_BASE_SEPOLIA =
    "0x10b5Be494C2962A7B318aFB63f0Ee30b959D000b";

  // Simple retry logic - try up to 3 times
  for (let attempt = 0; attempt < 3; attempt++) {
    try {
      const response = await fetch(
        `${RAIN_API_URL}/issuing/users/${userId}/contracts`,
        {
          headers: { "Api-Key": process.env.RAIN_API_KEY! },
        }
      );

      const contracts = await response.json();
      const baseContract = contracts.find(
        (c: { chainId: number }) => c.chainId === BASE_SEPOLIA_CHAIN_ID
      );

      if (baseContract) {
        const rusdToken = baseContract.tokens.find(
          (t: { address: string }) => t.address === RUSD_CONTRACT_BASE_SEPOLIA
        );
        return {
          contractId: baseContract.id,
          depositAddress: baseContract.depositAddress,
          tokens: baseContract.tokens,
          rusdBalance: rusdToken?.balance || "0.0",
        };
      }

      // Wait 2 seconds before retry if no contract found
      if (attempt < 2)
        await new Promise((resolve) => setTimeout(resolve, 2000));
    } catch (error) {
      if (attempt === 2) throw error;
      await new Promise((resolve) => setTimeout(resolve, 2000));
    }
  }
  throw new Error("No Base Sepolia contract found after retries");
}

/**
* Gets user's credit balances and spending power
* Returns simplified balance info in dollars (converted from cents)
*/
export async function getRainUserCreditBalances(userId: string) {
  const response = await fetch(
    `${RAIN_API_URL}/issuing/users/${userId}/balances`,
    {
      headers: { "Api-Key": process.env.RAIN_API_KEY! },
    }
  );

  const data = await response.json();
  return {
    creditLimit: data.creditLimit / 100,
    spendingPower: data.spendingPower / 100,
    balanceDue: data.balanceDue / 100,
  };
}

/**
* Gets Rain user status by checking application status
* Returns basic user info and approval status
*/
export async function getRainUserStatus(userId: string) {
  const response = await fetch(
    `${RAIN_API_URL}/issuing/applications/user/${userId}`,
    {
      headers: { "Api-Key": process.env.RAIN_API_KEY! },
    }
  );

  const result = await response.json();
  return {
    userId: result.id,
    applicationStatus: result.applicationStatus,
    email: result.email,
    isActive: result.isActive,
  };
}

/**
* Finds Rain user by wallet address
* Returns array of users (should be 0 or 1 for unique wallets)
*/
export async function getRainUserByWalletAddress(walletAddress: string) {
  const response = await fetch(`${RAIN_API_URL}/issuing/users?limit=100`, {
    headers: { "Api-Key": process.env.RAIN_API_KEY! },
  });

  const users = await response.json();
  return users.filter(
    (user: { walletAddress: string }) => user.walletAddress === walletAddress
  );
}
4

Create the Main Integration Component

Build a React component that handles both authentication and the Rain integration flow in one place.
components/rain-integration.tsx
"use client";

import { useState, useEffect } from "react";
import { useAuth, useWallet, EVMWallet } from "@crossmint/client-sdk-react-ui";
import { encodeFunctionData } from "viem";
import {
  createRainUserApplication,
  createRainUserContract,
  issueRainCard,
  getRainUserByWalletAddress,
  getRainUserContracts,
  getRainUserCards,
  getRainUserCreditBalances,
  getRainUserStatus,
} from "../actions/rain";

const RUSD_CONTRACT_BASE_SEPOLIA = "0x10b5Be494C2962A7B318aFB63f0Ee30b959D000b";

export function RainIntegration() {
  const { wallet } = useWallet();
  const { user, login, logout } = useAuth();
  const [step, setStep] = useState<"signup" | "contract" | "card" | "funded">(
    "signup"
  );
  const [rainUserId, setRainUserId] = useState("");
  const [contractAddress, setContractAddress] = useState("");
  const [contractData, setContractData] = useState<{
    contractId: string;
    depositAddress: string;
    tokens: Array<{ address: string; balance: string }>;
    rusdBalance: string;
  } | null>(null);
  const [cardData, setCardData] = useState<{
    id: string;
    type: string;
    status: string;
    last4: string;
    expirationMonth: string;
    expirationYear: string;
    limit: {
      amount: number;
      frequency: string;
    };
  } | null>(null);
  const [loading, setLoading] = useState(false);
  const [initialLoading, setInitialLoading] = useState(true);
  const [creditBalances, setCreditBalances] = useState<{
    creditLimit: number;
    spendingPower: number;
    balanceDue: number;
  } | null>(null);
  const [isRefreshingBalances, setIsRefreshingBalances] = useState(false);

  // Comprehensive state determination on wallet connection
  useEffect(() => {
    const determineCurrentState = async () => {
      if (!wallet?.address || !user?.email) {
        setInitialLoading(false);
        return;
      }
      setInitialLoading(true);
      try {
        // Step 1: Check if user exists
        const existingUsers = await getRainUserByWalletAddress(wallet.address);
        if (existingUsers.length === 0) {
          // No user exists - start from signup
          setStep("signup");
          return;
        }
        // User exists - get their details
        const rainUser = existingUsers[0];
        setRainUserId(rainUser.id);
        // Step 2: Check user status
        const userStatus = await getRainUserStatus(rainUser.id);
        if (userStatus.applicationStatus !== "approved") {
          setStep("signup");
          return;
        }
        // Step 3: Check for contracts
        try {
          const contractInfo = await getRainUserContracts(rainUser.id);
          setContractAddress(contractInfo.depositAddress);
          setContractData(contractInfo);

          // Step 4: Check for cards
          try {
            const cards = await getRainUserCards(rainUser.id);
            if (cards.length > 0) {
              // User has cards - check if funded
              const card = cards[0];
              setCardData({
                id: card.id,
                type: card.type,
                status: card.status,
                last4: card.last4,
                expirationMonth: card.expirationMonth,
                expirationYear: card.expirationYear,
                limit: card.limit,
              });
              // Get credit balances to determine if funded
              const balances = await getRainUserCreditBalances(rainUser.id);
              setCreditBalances(balances);
              // If there's spending power, consider it funded
              if (balances.spendingPower > 0) {
                setStep("funded");
              } else {
                setStep("card");
              }
            } else {
              // Contract exists but no card
              setStep("contract");
            }
          } catch {
            // Error getting cards - assume no cards exist
            setStep("contract");
          }
        } catch {
          // No contract exists - need to create one
          setStep("contract");
        }
      } catch (error) {
        console.error("Error determining state:", error);
        setStep("signup"); // Default to signup on error
      } finally {
        setInitialLoading(false);
      }
    };
    determineCurrentState();
  }, [wallet?.address, user?.email]);

  const handleSignup = async () => {
    if (!wallet || !user?.email) return;
    setLoading(true);
    try {
      const result = await createRainUserApplication({
        firstName: user.email,
        lastName: "approved", // Skip KYC for demo
        email: user.email,
        walletAddress: wallet.address,
      });
      setRainUserId(result.userId);
      // Move to contract step - don't auto-create contract here
      // Let the user manually trigger contract creation
      setStep("contract");
    } catch (error) {
      console.error("Signup failed:", error);
      alert("Signup failed: " + error);
    } finally {
      setLoading(false);
    }
  };

  const handleCreateContract = async () => {
    if (!rainUserId) return;
    setLoading(true);
    try {
      // Create the contract
      await createRainUserContract(rainUserId, 84532);
      // Wait a bit for contract to be created
      await new Promise((resolve) => setTimeout(resolve, 3000));
      // Try to get contract info with retry logic
      let contractInfo;
      let attempts = 0;
      const maxAttempts = 5;
      while (attempts < maxAttempts) {
        try {
          contractInfo = await getRainUserContracts(rainUserId);
          if (contractInfo?.depositAddress) {
            break;
          }
        } catch {
          console.log(`Attempt ${attempts + 1} failed, retrying...`);
        }
        attempts++;
        if (attempts < maxAttempts) {
          await new Promise((resolve) => setTimeout(resolve, 2000));
        }
      }
      if (contractInfo?.depositAddress) {
        setContractAddress(contractInfo.depositAddress);
        setContractData(contractInfo);
        alert("✅ Smart contract created successfully!");
      } else {
        throw new Error(
          "Contract was created but details are not yet available. Please refresh the page."
        );
      }
    } catch (error) {
      console.error("Contract creation failed:", error);
      alert(
        "Contract creation may have succeeded but details are not yet available. Please refresh the page to check."
      );
    } finally {
      setLoading(false);
    }
  };

  const handleIssueCard = async () => {
    if (!rainUserId) return;

    setLoading(true);
    try {
      const card = await issueRainCard(rainUserId, user?.email || "Demo User");
      setCardData({
        id: card.id,
        type: card.type,
        status: card.status,
        last4: card.last4,
        expirationMonth: card.expirationMonth,
        expirationYear: card.expirationYear,
        limit: card.limit,
      });
      // Fetch initial credit balances after card issuance
      await handleRefreshCreditBalances();
      setStep("card");
    } catch (error) {
      console.error("Card issuance failed:", error);
      alert("Card issuance failed: " + error);
    } finally {
      setLoading(false);
    }
  };

  const handleRefreshCreditBalances = async () => {
    if (!rainUserId) return;
    setIsRefreshingBalances(true);
    try {
      const balances = await getRainUserCreditBalances(rainUserId);
      setCreditBalances(balances);
    } catch (error) {
      console.error("Failed to refresh credit balances:", error);
    } finally {
      setIsRefreshingBalances(false);
    }
  };

  const handleFundCard = async () => {
    if (!wallet || !contractAddress) return;
    setLoading(true);
    try {
      const evmWallet = EVMWallet.from(wallet);
      // Mint RUSD tokens
      const mintData = encodeFunctionData({
        abi: [
          {
            name: "mint",
            type: "function",
            inputs: [{ name: "_amountDollars_Max100", type: "uint256" }],
            outputs: [],
          },
        ],
        functionName: "mint",
        args: [BigInt(10)], // Mint $10 RUSD
      });
      await evmWallet.sendTransaction({
        to: RUSD_CONTRACT_BASE_SEPOLIA,
        data: mintData,
        value: BigInt(0),
        chain: "base-sepolia", // Using Base Sepolia - see Rain docs for other supported chains
      });
      // Send RUSD to contract
      await wallet.send(contractAddress, RUSD_CONTRACT_BASE_SEPOLIA, "10");
      // Refresh credit balances after funding
      await handleRefreshCreditBalances();
      setStep("funded");
    } catch (error) {
      console.error("Funding failed:", error);
    } finally {
      setLoading(false);
    }
  };

  // Show login screen if user is not authenticated
  if (!user) {
    return (
      <div className="max-w-md mx-auto p-6 bg-white rounded-lg shadow text-center">
        <h2 className="text-2xl font-bold mb-4">Rain Card Demo</h2>
        <p className="text-gray-600 mb-6">
          Sign in to create your account and get started with crypto-backed
          credit cards
        </p>
        <button
          onClick={login}
          className="bg-blue-600 text-white py-2 px-6 rounded-lg hover:bg-blue-700 transition-colors"
        >
          Login to Get Started
        </button>
      </div>
    );
  }

  // Show loading screen while determining current state
  if (initialLoading) {
    return (
      <div className="max-w-md mx-auto p-6 bg-white rounded-lg shadow text-center">
        <h2 className="text-2xl font-bold mb-4">Rain Card Demo</h2>
        <div className="flex items-center justify-center py-8">
          <div className="w-8 h-8 border-4 border-blue-600 border-t-transparent rounded-full animate-spin" />
          <p className="text-gray-600 ml-3">
            Checking your Rain account status...
          </p>
        </div>
      </div>
    );
  }

  return (
    <div className="max-w-md mx-auto p-6 bg-white rounded-lg shadow">
      {/* Header with user info and logout */}
      <div className="flex justify-between items-center mb-6">
        <h2 className="text-xl font-bold">Rain Card Demo</h2>
        <div className="flex items-center gap-3">
          {wallet && (
            <span className="text-xs text-gray-500">
              {wallet.address.slice(0, 6)}...{wallet.address.slice(-4)}
            </span>
          )}
          <button
            onClick={logout}
            className="text-sm text-gray-600 hover:text-gray-800 transition-colors"
          >
            Logout
          </button>
        </div>
      </div>
      {/* Card Data Display - Show whenever card data exists */}
      {cardData?.last4 && (
        <div className="mb-6">
          {/* Credit Card Visual */}
          <div className="relative w-full max-w-sm mx-auto aspect-[1.586/1] bg-gray-900 rounded-lg shadow-2xl overflow-hidden">
            {/* Card Background Pattern */}
            <div className="absolute inset-0 opacity-10">
              <div className="absolute top-4 right-4 w-12 h-12 border-2 border-white rounded-full"></div>
              <div className="absolute top-4 right-20 w-8 h-8 border-2 border-white rounded-full"></div>
            </div>
            {/* Card Content */}
            <div className="relative h-full p-6 flex flex-col justify-between text-white">
              {/* Top Section */}
              <div className="flex justify-between items-start">
                <div>
                  <div className="text-xs opacity-70 mb-1">VIRTUAL CARD</div>
                  <div className="text-sm font-semibold">Rain Card</div>
                </div>
                <div
                  className={`px-2 py-1 rounded text-xs font-medium ${
                    cardData.status === "active"
                      ? "bg-green-500 text-white"
                      : "bg-yellow-500 text-black"
                  }`}
                >
                  {cardData.status.toUpperCase()}
                </div>
              </div>
              {/* Card Number */}
              <div className="my-4">
                <div className="font-mono text-lg tracking-wider">
                  •••• •••• •••• {cardData.last4}
                </div>
              </div>
              {/* Bottom Section */}
              <div className="flex justify-between items-end">
                <div>
                  {cardData.expirationMonth && cardData.expirationYear && (
                    <div>
                      <div className="text-xs opacity-70">VALID THRU</div>
                      <div className="font-mono text-sm">
                        {cardData.expirationMonth}/
                        {cardData.expirationYear.slice(-2)}
                      </div>
                    </div>
                  )}
                </div>
                <div className="text-right">
                  <div className="text-xs opacity-70">SPENDING POWER</div>
                  <div className="text-sm font-semibold">
                    ${creditBalances?.spendingPower?.toFixed(0) || "0"}
                  </div>
                </div>
              </div>
            </div>
          </div>
          {/* Credit Balance Refresh - Only show when card exists */}
          {creditBalances && (
            <div className="text-center mt-2">
              <button
                onClick={handleRefreshCreditBalances}
                disabled={isRefreshingBalances}
                className="text-xs px-3 py-1 bg-gray-100 text-gray-700 rounded hover:bg-gray-200 disabled:opacity-50"
              >
                {isRefreshingBalances ? "Refreshing..." : "Refresh Balance"}
              </button>
            </div>
          )}
        </div>
      )}
      {step === "signup" && (
        <div>
          <p className="mb-4">
            Create your Rain account and get approved instantly
          </p>
          <button
            onClick={handleSignup}
            disabled={loading || !wallet}
            className="w-full bg-blue-600 text-white py-2 px-4 rounded disabled:opacity-50"
          >
            {loading ? "Creating Account..." : "Sign Up for Rain Card"}
          </button>
        </div>
      )}
      {step === "contract" && (
        <div>
          {!contractData ? (
            <div>
              <p className="mb-4">
                ✅ Account approved! Ready to create your smart contract.
              </p>
              <button
                onClick={handleCreateContract}
                disabled={loading}
                className="w-full bg-blue-600 text-white py-2 px-4 rounded disabled:opacity-50 mb-4"
              >
                {loading
                  ? "Creating Smart Contract..."
                  : "Create Smart Contract"}
              </button>
            </div>
          ) : (
            <div>
              <p className="mb-4">
                🏗️ Smart contract created! Ready to issue your card.
              </p>
              <button
                onClick={handleIssueCard}
                disabled={loading}
                className="w-full bg-green-600 text-white py-2 px-4 rounded disabled:opacity-50"
              >
                {loading ? "Issuing Card..." : "Issue Virtual Card"}
              </button>
            </div>
          )}
        </div>
      )}
      {step === "card" && (
        <div>
          <p className="mb-4">🎉 Card issued! Fund it to start spending.</p>

          <button
            onClick={handleFundCard}
            disabled={loading}
            className="w-full bg-purple-600 text-white py-2 px-4 rounded disabled:opacity-50"
          >
            {loading ? "Funding..." : "Fund Card ($10 RUSD)"}
          </button>
        </div>
      )}
      {step === "funded" && (
        <div className="text-center">
          <p className="text-green-600 font-bold mb-2">🎉 Card Ready!</p>
          <p className="text-sm text-gray-600">
            Your card is funded and ready to use for purchases. Balance may take
            a moment to update.
          </p>
        </div>
      )}
    </div>
  );
}
5

Wire Everything Together

Update your main page component to use the RainIntegration component directly.
app/page.tsx
import { RainIntegration } from "./components/rain-integration";

export default function HomePage() {
  return (
    <div className="min-h-screen bg-gray-50 py-8">
      <RainIntegration />
    </div>
  );
}

Next Steps

Need Help?

Contact Sales

Contact our team if you’re interested in planning your integration.

Resources