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.
- Virtual Visa card issuance
- Crypto-to-card funding with RUSD tokens
- Real-time balance and transaction monitoring
- Next.js >=15 (with App Router)
- React Server Actions for API integration
- Crossmint React SDK for wallet and authentication
- Rain API for card issuance (contact the rain team for access)
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-appInstall Dependencies
Copy
Ask AI
# 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
Copy
Ask AI
"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
Copy
Ask AI
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
Copy
Ask AI
# 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
Copy
Ask AI
"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
Copy
Ask AI
"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
Copy
Ask AI
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
Learn Token Transfers
Send tokens between wallets and external addresses
Bring Your Own Auth
Use your own authentication system with Crossmint
Staging vs Production
Learn about staging and production environments
Need Help?
Contact Sales
Contact our team if you’re interested in planning your integration.

