Custody Wallets
Hold stablecoins without automatic conversion. The 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.
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
- Create —
POST /wallet/custodialwith a chain. Bakkt deploys a smart contract for the wallet. - Activate — the wallet starts in
Pendingwhile the contract deploys (1–2 minutes), then becomesActive. - Deposit — the user sends stablecoins to
smartContractAddress. Only show this address once the wallet isActive. - Request withdrawal —
POST /wallet/custodial/offrampreturns anofframpRequestUuidand triggers an OTP to the user. - Confirm —
POST /wallet/custodial/offramp/confirmwith the OTP executes the transfer (204 No Content). - Settle — funds off-ramp to fiat; track completion with the
cryptoToFiatwebhook. See Webhooks.
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 in
Pendingstatus 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 via
GET /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.
Updated 3 days ago
