Custody Wallets
🏦 Feature 4: Custody Wallets
Hold stablecoins without automatic conversion. User controls when to convert to fiat.
What Are Custody Wallets?
Custody wallets are standard stablecoin deposit addresses managed by Bakkt. Unlike on-ramp linked wallets, stablecoins deposited to custody addresses are held until the user manually triggers a conversion.
Key characteristics:
- ✅ Hold stablecoins without automatic conversion
- ✅ User controls conversion timing
- ✅ No Accounts API link required
- ✅ Works for both users and corporates
When to use:
- User wants to hold stablecoins and convert later
- Wait for favorable exchange rates
- Accumulate stablecoins over time before converting
- Don't need immediate fiat access
Custody vs On-Ramp Linked
| Feature | Custody Wallet | On-Ramp Linked Wallet |
|---|---|---|
| Conversion | Manual (user decides when) | Automatic (when fiat deposited) |
| Accounts API | Not required | Required (stablecoin fiat account link) |
| Use Case | Hold & convert later | Auto-convert fiat deposits |
| Endpoint | GET /user/wallet/{chain} | GET /user/wallet/{chain}?linked_bank_account_uuid=xxx |
User Custody Example
// No Accounts API needed for custody
// 1. Get custody wallet address
const wallet = await fetch('https://api.bakkt.com/stablecoin/user/wallet/polygon', {
headers: {
'Authorization': apiKey,
'user-uuid': userUuid
}
});
const walletData = await wallet.json();
console.log('Custody address:', walletData[0].address);
console.log('Chain:', walletData[0].chain);
// 2. User deposits crypto (held in custody)
// User sends USDC from their wallet to custody address
// 3. Later, when user wants to convert:
// User triggers off-ramp (see Off-Ramp feature above)
// → Adds remote bank account
// → Sends crypto to wallet
// → Receives fiat in bank accountCorporate Custody Example
const corporateUuid = 'YOUR_CORPORATE_UUID';
// Get corporate custody wallet
const wallet = await fetch(
`https://api.bakkt.com/stablecoin/corporate/${corporateUuid}/wallet/base`,
{
headers: {
'Authorization': apiKey,
'user-uuid': corporateUuid
}
}
).then(r => r.json());
// Returns custody address for holding crypto
// Corporate can accumulate and convert when ready
console.log('Corporate custody address:', wallet[0].address);Info:
Custody Benefit: Users can hold stablecoins in Bakkt custody (secure, insured) and convert to fiat whenever they choose, without needing an immediate Accounts API integration.
🔐 Feature 5: Smart Contract Custodial Wallets
Create smart contract-based custodial wallets for enhanced security and control. These wallets provide institutional-grade custody with programmable security rules.
What Are Smart Contract Custodial Wallets?
Smart contract custodial wallets are on-chain smart contracts that hold user funds with enhanced security features:
Key characteristics:
- ✅ Smart contract-based custody (not just EOA addresses)
- ✅ Enhanced security with programmable rules
- ✅ OTP-based withdrawal confirmation
- ✅ View balances across all tokens
- ✅ Per-chain wallet creation
When to use:
- Enterprise clients requiring institutional custody
- Applications needing programmable withdrawal rules
- Multi-signature or approval workflows
- Enhanced security requirements
How Smart Contract Wallets Work
Note: This section includes a sequence diagram showing the workflow. Refer to the original documentation for the visual diagram.
Create Custodial Wallet
Step 1: Create the wallet
// Create custodial wallet on Polygon
const wallet = await fetch('https://api.bakkt.com/stablecoin/wallet/custodial', {
method: 'POST',
headers: {
'Authorization': apiKey,
'bakkt-session-id': sessionId,
'user-uuid': userUuid,
'Content-Type': 'application/json'
},
body: JSON.stringify({
chain: 'Polygon'
})
});
const walletData = await wallet.json();
console.log('Wallet UUID:', walletData.walletUuid);
console.log('Merchant UUID:', walletData.merchantUuid);
console.log('Chain:', walletData.chain);Response:
{
"walletUuid": "c12345b5-7890-4a2f-8c2f-f3e913c12e5f",
"merchantUuid": "d12345b5-7890-4a2f-8c2f-f3e913c12e5f",
"chain": "polygon"
}Warning:
Wallet Status: Initially, the wallet will be inPendingstatus while the smart contract is deployed. Check status using GET endpoint.
Get Wallet Details & Status
Step 2: Check wallet status and get addresses
// Get custodial wallet details
const details = await fetch(
`https://api.bakkt.com/stablecoin/wallet/custodial/${walletUuid}`,
{
headers: {
'Authorization': apiKey,
'bakkt-session-id': sessionId,
'user-uuid': userUuid
}
}
);
const walletDetails = await details.json();
console.log('Status:', walletDetails.status); // "Pending" or "Active"
console.log('Smart Contract Address:', walletDetails.smartContractAddress);
console.log('Key Address:', walletDetails.smartContractKeyAddress);Response (Active wallet):
{
"walletUuid": "c12345b5-7890-4a2f-8c2f-f3e913c12e5f",
"merchantUuid": "d56789a1-2345-6789-abcd-1234567890ef",
"chain": "polygon",
"status": "Active",
"smartContractAddress": "0xabc123...def456",
"smartContractKeyAddress": "0xkey123...abc789"
}Wallet Lifecycle:
Pending: Smart contract deployment in progress (1-2 minutes)Active: Wallet ready to receive deposits
Check Wallet Balance
View balance across all tokens:
// Get current balance
const balance = await fetch('https://api.bakkt.com/stablecoin/wallet/balance', {
headers: {
'Authorization': apiKey,
'bakkt-session-id': sessionId,
'user-uuid': userUuid
}
});
const balanceData = await balance.json();
console.log('Balance:', balanceData.balance);Response:
{
"balance": 100.23
}Note:
Balance Tracking: The balance endpoint shows the total value of all assets in the custodial wallet, aggregated across all supported tokens.
Request Offramp from Custodial Wallet
Step 3: Initiate withdrawal with OTP
// Request offramp (triggers OTP email/SMS)
const offrampRequest = await fetch(
'https://api.bakkt.com/stablecoin/wallet/custodial/offramp',
{
method: 'POST',
headers: {
'Authorization': apiKey,
'bakkt-session-id': sessionId,
'user-uuid': userUuid,
'Content-Type': 'application/json'
},
body: JSON.stringify({
walletUuid: 'c12345b5-7890-4a2f-8c2f-f3e913c12e5f',
chain: 'polygon',
token: 'USDC',
amount: 25.0,
offrampAddress: '0xF2f5F4Df47664C1030d3FAAC2E5D6b927d0717B1'
})
}
);
const offrampData = await offrampRequest.json();
console.log('Offramp Request UUID:', offrampData.offrampRequestUuid);
console.log('Recipient UUID:', offrampData.beneficiaryUuid);
// OTP sent to user's email/phoneResponse:
{
"offrampRequestUuid": "f19c111e-5a2e-41c2-9f00-8e7d0b8c2e31",
"merchantUuid": "e0123abc-7890-4567-a1b2-c3d4e5f67890",
"walletUuid": "a12345bc-6789-4def-9012-3456789abcde",
"token": "USDC",
"amount": 20.5,
"beneficiaryUuid": "b98765cb-3210-4321-8765-abcdef987654"
}Warning:
OTP Security: An OTP (One-Time Password) will be sent to the user's registered email/phone. This must be provided to confirm the withdrawal.
Confirm Offramp with OTP
Step 4: Submit OTP to execute withdrawal
// User receives OTP via email/SMS
const userOTP = '123456'; // 6-digit code
// Confirm offramp
const confirmation = await fetch(
'https://api.bakkt.com/stablecoin/wallet/custodial/offramp/confirm',
{
method: 'POST',
headers: {
'Authorization': apiKey,
'bakkt-session-id': sessionId,
'user-uuid': userUuid,
'Content-Type': 'application/json'
},
body: JSON.stringify({
offrampRequestUuid: 'f19c111e-5a2e-41c2-9f00-8e7d0b8c2e31',
otp: userOTP
})
}
);
// Returns 204 No Content on success
if (confirmation.status === 204) {
console.log('Offramp confirmed! Funds will be transferred.');
}Sign In with Custodial Wallet
Authenticate using a custodial wallet:
// Sign in or link custodial wallet
const signin = await fetch('https://api.bakkt.com/stablecoin/wallet/custodial/signin', {
method: 'POST',
headers: {
'Authorization': apiKey,
'bakkt-session-id': sessionId,
'user-uuid': userUuid,
'Content-Type': 'application/json'
},
body: JSON.stringify({
walletUuid: 'c12345b5-7890-4a2f-8c2f-f3e913c12e5f'
})
});
const signinData = await signin.json();
console.log('Message to sign:', signinData.message);
console.log('Signature:', signinData.signature);
console.log('Wallet UUID:', signinData.walletUuid);Response:
{
"message": "Please sign this message to authenticate your wallet.",
"signature": "0xabcdef1234567890",
"merchantUuid": "f4b32e1e-d9e0-4b4f-bbb3-6fc2f8a7b9dc",
"walletUuid": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
}Complete Custodial Workflow
End-to-end example:
// === SETUP: Create & Activate Custodial Wallet ===
// 1. Create custodial wallet
const createResponse = await bakkt.custodial.create({
chain: 'Polygon'
});
const walletUuid = createResponse.walletUuid;
// 2. Wait for activation (poll until Active)
let walletStatus = 'Pending';
while (walletStatus === 'Pending') {
await new Promise(r => setTimeout(r, 5000)); // Wait 5 seconds
const statusCheck = await bakkt.custodial.getDetails(walletUuid);
walletStatus = statusCheck.status;
if (walletStatus === 'Active') {
console.log('Wallet active!');
console.log('Deposit address:', statusCheck.smartContractAddress);
}
}
// === DEPOSIT: User Sends Crypto ===
// 3. User deposits USDC to smart contract address
// (happens in user's wallet app, e.g., MetaMask)
// 4. Check balance
const balance = await bakkt.custodial.getBalance();
console.log('Current balance:', balance.balance, 'USDC');
// === WITHDRAW: Offramp to Fiat ===
// 5. Add remote bank account (if not already added)
const bankAccount = await bakkt.stablecoin.createRemoteBankAccount({
account_name: 'My Bank',
main_recipient: true,
account_details: {
currency: 'EUR',
iban: 'DE89370400440532013000'
}
});
// 6. Request offramp (triggers OTP)
const offrampRequest = await bakkt.custodial.requestOfframp({
walletUuid,
chain: 'polygon',
token: 'USDC',
amount: 100,
offrampAddress: bankAccount.receiving_address
});
console.log('OTP sent to user. Request UUID:', offrampRequest.offrampRequestUuid);
// 7. User receives OTP and submits
const userOTP = promptUser('Enter OTP from email/SMS:');
// 8. Confirm offramp
await bakkt.custodial.confirmOfframp({
offrampRequestUuid: offrampRequest.offrampRequestUuid,
otp: userOTP
});
console.log('Withdrawal confirmed! Processing...');
// 9. Monitor via webhooks
webhook.on('cryptoToFiat SUCCESS', (data) => {
console.log(`${data.amountCrypto} USDC → ${data.amountFiat} EUR`);
console.log('Fiat sent to bank account');
});Security Features
Smart Contract Security
On-Chain Custody: Funds are held in audited smart contracts, not hot wallets.
**Programmable Rules**: Withdrawal limits, multi-sig, time locks can be configured.
**Transparent**: All transactions visible on-chain via block explorer.
OTP Confirmation
Two-Factor Withdrawal: Every withdrawal requires both API auth + OTP.
**6-Digit Code**: Sent via email or SMS to registered user.
**Time-Limited**: OTP expires after 10 minutes for security.
Balance Tracking
Real-Time: Query balance at any time via GET /wallet/balance.
**Multi-Token**: Aggregated value across all supported stablecoins.
**Webhook Updates**: Receive notifications on balance changes.
Custodial vs Standard Wallets
| Feature | Smart Contract Custodial | Standard Custody | On-Ramp Linked |
|---|---|---|---|
| Security | Smart contract + OTP | Bakkt custody | Bakkt custody |
| Withdrawal | Requires OTP | Automatic | Automatic |
| Use Case | High-security holding | Simple holding | Auto-convert |
| Setup | POST /wallet/custodial | GET /user/wallet/{chain} | stablecoin fiat account link |
| Best For | Enterprises, high-value | Individual users | Fiat depositors |
Supported Chains for Custodial
Custodial wallets can be created on all supported EVM chains:
- Ethereum (mainnet)
- Polygon
- Arbitrum
- Optimism
- Base
- BSC (Binance Smart Chain)
- Avalanche
Note:
Solana Support: Smart contract custodial wallets are currently available only on EVM-compatible chains. For Solana, use standard custody wallets viaGET /user/wallet/solana.
Best Practices for Custodial Wallets
Wallet Creation
- Create wallets during user onboarding
-
Wait for
Activestatus before displaying deposit address -
Store walletUuid for future operations
-
Create one wallet per chain if multi-chain support needed
-
Deposits
- Only show deposit address when status is
Active-
Display clear instructions on supported tokens
-
Warn about minimum deposit amounts
-
Provide transaction hash for tracking
-
Withdrawals
- Implement OTP input UI for user
-
Show OTP expiration timer (10 minutes)
-
Handle OTP errors gracefully (invalid, expired)
-
Provide "resend OTP" option
-
Security
- Never store OTPs in logs or databases
- Implement rate limiting on OTP attempts
- Show clear security warnings to users
- Monitor for suspicious withdrawal patterns
Error Handling
Common errors and solutions:
| Error | Cause | Solution |
|---|---|---|
Wallet status: Pending | Smart contract still deploying | Wait and retry GET endpoint |
Invalid OTP | Wrong code entered | Prompt user to re-enter |
OTP expired | >10 minutes passed | Request new offramp |
Insufficient balance | Not enough funds | Check balance first |
Chain not supported | Solana smart contracts | Use standard custody |
Info:
Production Use: Custodial wallets are production-ready and used by enterprise clients for institutional custody requirements.
