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

FeatureCustody WalletOn-Ramp Linked Wallet
ConversionManual (user decides when)Automatic (when fiat deposited)
Accounts APINot requiredRequired (stablecoin fiat account link)
Use CaseHold & convert laterAuto-convert fiat deposits
EndpointGET /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 account

Corporate 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

  1. Create — POST /wallet/custodial with a chain. Bakkt deploys a smart contract for the wallet.
  2. Activate — the wallet starts in Pending while the contract deploys (1–2 minutes), then becomes Active.
  3. Deposit — the user sends stablecoins to smartContractAddress. Only show this address once the wallet is Active.
  4. Request withdrawal — POST /wallet/custodial/offramp returns an offrampRequestUuid and triggers an OTP to the user.
  5. Confirm — POST /wallet/custodial/offramp/confirm with the OTP executes the transfer (204 No Content).
  6. Settle — funds off-ramp to fiat; track completion with the cryptoToFiat webhook. 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 Pending status 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/phone

Response:

{
  "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

FeatureSmart Contract CustodialStandard CustodyOn-Ramp Linked
SecuritySmart contract + OTPBakkt custodyBakkt custody
WithdrawalRequires OTPAutomaticAutomatic
Use CaseHigh-security holdingSimple holdingAuto-convert
SetupPOST /wallet/custodialGET /user/wallet/{chain}stablecoin fiat account link
Best ForEnterprises, high-valueIndividual usersFiat 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 Active status 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:

ErrorCauseSolution
Wallet status: PendingSmart contract still deployingWait and retry GET endpoint
Invalid OTPWrong code enteredPrompt user to re-enter
OTP expired>10 minutes passedRequest new offramp
Insufficient balanceNot enough fundsCheck balance first
Chain not supportedSolana smart contractsUse standard custody

Info: Production Use: Custodial wallets are production-ready and used by enterprise clients for institutional custody requirements.


Did this page help you?