User Management

Overview

User management is the foundation of onboarding individuals to the Bakkt platform. This guide covers the complete user lifecycle from creation and authentication and later through verification and profile updates.

User status lifecycle

Users progress through these statuses as they complete onboarding. The happy path:

PHONE_VERIFICATION_NEEDED*  →  KYC_NEEDED  →  PENDING_KYC_DATA  →  KYC_PENDING  →  FULL_USER

At any point along that path the user can move to a terminal state instead:

  • SOFT_KYC_FAILED — recoverable; the user can correct data and retry.
  • HARD_KYC_FAILED — permanent; the user cannot be served.
  • SUSPENDED — compliance hold; contact support.

* PHONE_VERIFICATION_NEEDED only applies to U.S. users; everyone else starts at KYC_NEEDED.

StatusDescriptionWhat to do
PHONE_VERIFICATION_NEEDED*Phone verification required (U.S. users only)Send the OTP via POST /user/verification/phone/send and verify with POST /user/verification/phone/check
KYC_NEEDEDUser exists but has not started KYCPrompt the user to start KYC (POST /user/kyc/applicant)
PENDING_KYC_DATAKYC started but documents are missingInspect missing docs with GET /user/kyc/applicant and prompt the user to upload them
KYC_PENDINGKYC submitted and under reviewWait for the verification webhook
FULL_USERKYC approvedOnly users in this state can transact
SOFT_KYC_FAILEDRecoverable failureCheck the rejection reason on the webhook payload, guide the user to fix the issue, and retry
HARD_KYC_FAILEDUnrecoverable failureInform the user; no retry is possible
SUSPENDEDCompliance hold; user cannot take any actionContact support for clarification or appeal

See Best practices below for handling each state, and KYC & KYB for the full list of failure reasons.

Spec note: the GET /user response only enumerates the post-creation statuses (KYC_NEEDED onwards). PHONE_VERIFICATION_NEEDED is the implicit initial state for U.S. users immediately after POST /user, before phone verification completes.

Required fields by country

The four globally-required fields on POST /user are first_name, last_name, email, and country. Some jurisdictions require additional fields:

RegionAdditional required fieldsNotes
EuropeGlobally-required fields only.
U.S.phoneTriggers the phone verification flow. ssn and ip_address may be required for some users.
Nigeriatarget_address, bvnbvn is the Nigerian Bank Verification Number — exactly 11 digits, no dashes or spaces.
India, PakistanGlobally-required fields only, but additional product restrictions apply at runtime.

If hash validation is enabled for your merchant (it's opt-in — typically only required when you collect T&C acceptance in your own UI rather than through the Sumsub-hosted flow), every user must accept the latest Terms and Conditions, Privacy Policy, and Terms of Service, and you must pass the corresponding hashes (accepted_terms_and_conditions_hash, accepted_privacy_policy_hash, accepted_terms_of_service_hash) on creation. See Terms and Conditions Hashes for the source documents, how to compute the hashes, and how to react when Bakkt re-versions a document. If hash validation isn't enabled for your merchant, omit these fields.

Core user operations

Create a user

POST /user creates a new user with the required per-country inputs and returns a user_uuid you'll use everywhere else.

const response = await fetch('https://sandbox.api.bakkt.com/user', {
  method: 'POST',
  headers: {
    'Authorization': 'YOUR_API_KEY',
    'Content-Type': 'application/json'
  },
  body: JSON.stringify({
    first_name: 'Alice',
    last_name: 'Smith',
    email: '[email protected]',
    country: 'GB',
    target_address: '0xFaFe15f71861609464a4ACada29a92c5bC01637a',
    // The three accepted_* fields below are only required if hash validation is
    // enabled for your merchant. Omit them otherwise and confirm the required
    // values with your Bakkt onboarding configuration.
    accepted_terms_and_conditions_hash: 'base64_hash_here',
    accepted_privacy_policy_hash: 'base64_hash_here',
    accepted_terms_of_service_hash: 'base64_hash_here'
  })
});

const { user_uuid, status } = await response.json(); // 201 Created

Update user profile

PATCH /user updates profile details, addresses, and preferences. Most fields can be changed freely before the user reaches FULL_USER; after that, only a subset is editable.

Restricted after FULL_USER: first_name, last_name, date_of_birth, email, title, bvn, ssn, occupation, mailing_address, third_party_end_user_agreement_hash. Contact support if you need to change one of these for an already-verified user.

const response = await fetch('https://sandbox.api.bakkt.com/user', {
  method: 'PATCH',
  headers: {
    'Authorization': 'YOUR_API_KEY',
    'bakkt-session-id': sessionId,
    'Content-Type': 'application/json'
  },
  body: JSON.stringify({
    target_address: '0x_NEW_ETH_ADDRESS',
    target_solana_address: 'NEW_SOLANA_ADDRESS'
  })
}); // 204 No Content

Target address

Target address is the user's external wallet (MetaMask, Coinbase, hardware wallet, etc.) where on-ramp conversions are sent by default. This is not a Bakkt custody address.

  • EVM chains: set target_address (Ethereum-format 0x…).
  • Solana: set target_solana_address.
  • Multi-chain: set both.

Address types. For each address you must also tell Bakkt how it's custodied:

  • SELF_HOSTED — user controls their own private keys (MetaMask, hardware wallet).
  • HOSTED — held by an exchange or custodian (Coinbase, Binance, …). When hosted, also pass target_address_vasp (or target_solana_address_vasp for Solana) with the VASP name.

Get user details

GET /user returns the full profile, including current status and verification timestamps. You can authenticate with either bakkt-session-id (an active user session) or user-uuid (server-to-server lookup).

const response = await fetch('https://sandbox.api.bakkt.com/user', {
  method: 'GET',
  headers: {
    'Authorization': 'YOUR_API_KEY',
    'bakkt-session-id': sessionId // or 'user-uuid': userUuid
  }
});

const user = await response.json();

Example response:

{
  "first_name": "Alice",
  "last_name": "Smith",
  "email": "[email protected]",
  "target_address": "0xFaFe15f71861609464a4ACada29a92c5bC01637a",
  "target_solana_address": "3KWxkxHLnxTqg1PMXUljpeFffzYxS7dziqWvJgQNg8dq",
  "status": "FULL_USER",
  "last_kyc_successful_check": "2024-01-15T10:30:00Z",
  "linked_corporates_uuid": ["corp-uuid-1", "corp-uuid-2"]
}

Phone verification

Required for U.S. users only. Skip this section for users in other countries.

Trigger an OTP delivery, then exchange the code for a verification result. Both calls require an authenticated session.

1. Send the OTPPOST /user/verification/phone/send

await fetch('https://sandbox.api.bakkt.com/user/verification/phone/send', {
  method: 'POST',
  headers: {
    'Authorization': 'YOUR_API_KEY',
    'bakkt-session-id': sessionId
  }
}); // 200 { "success": true }

The code is sent to the phone number stored on the user.

2. Verify the OTPPOST /user/verification/phone/check

const response = await fetch('https://sandbox.api.bakkt.com/user/verification/phone/check', {
  method: 'POST',
  headers: {
    'Authorization': 'YOUR_API_KEY',
    'bakkt-session-id': sessionId,
    'Content-Type': 'application/json'
  },
  body: JSON.stringify({ code: '123456' })
});

const { success, status } = await response.json(); // status: 'approved' | 'pending' | 'rejected'

A status of approved clears the user out of PHONE_VERIFICATION_NEEDED and into KYC_NEEDED.

Best practices

Common user-creation pitfalls

Email or phone number is not unique. Email must be unique per merchant. For U.S. users, both email and phone must be unique before POST /user will succeed — duplicates surface as 409 Conflict.

Missing target addresses. Always set target_address at creation for digital assets flows; add target_solana_address if you'll support Solana.

Missing Terms hashes (only if hash validation is enabled for your merchant). When the feature is enabled, collect and store the user-accepted hashes (accepted_terms_and_conditions_hash, accepted_privacy_policy_hash, accepted_terms_of_service_hash) at creation time and re-collect them on document re-versioning — Bakkt fires an entityStatusUpdate / MISSING_TERMS_AND_CONDITIONS_SIGNED webhook when a previously verified user falls out of compliance. See Terms and Conditions Hashes for the full workflow and how to confirm whether hash validation is enabled for your merchant.

Account state management

Account suspended. A SUSPENDED user cannot transact or update data. The change is announced via webhook. If the hold is lifted, re-KYC is not required. Direct affected users to support for clarification or appeal.

Account deactivated (vIBAN). A user's vIBAN may be deactivated after 12 months of inactivity. Merchants are notified in advance with the list of affected vIBANs. Deactivation is final — reopening requires a fresh KYC by the user.

Complete user onboarding workflow

End-to-end example using curl and jq. Replace YOUR_API_KEY with your sandbox API key. The shell variables (USER_UUID, SESSION_ID, KYC_TOKEN) carry state from one step to the next.

BASE_URL="https://sandbox.api.bakkt.com/onboarding"
API_KEY="YOUR_API_KEY"

# 1. Create the user.
#    The accepted_* fields are required only if hash validation is enabled
#    for your merchant; omit them otherwise (see /docs/terms-and-conditions-hashes).
USER_UUID=$(curl -sS -X POST "$BASE_URL/user" \
  -H "Authorization: $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "first_name": "Alice",
    "last_name": "Smith",
    "email": "[email protected]",
    "country": "GB",
    "target_address": "0xFaFe15f71861609464a4ACada29a92c5bC01637a",
    "accepted_terms_and_conditions_hash": "base64_hash_here",
    "accepted_privacy_policy_hash": "base64_hash_here",
    "accepted_terms_of_service_hash": "base64_hash_here"
  }' | jq -r '.user_uuid')

echo "Created user: $USER_UUID"

# 2. Authenticate via Email OTP (see /docs/authentication for the SIWE alternative).
#    Step 2a: Bakkt emails the OTP to the user — empty 200 response.
curl -sS -X POST "$BASE_URL/auth/login" \
  -H "Authorization: $API_KEY" \
  -H "Content-Type: application/json" \
  -d "{\"user_uuid\": \"$USER_UUID\"}"

# Step 2b: Exchange the OTP the user typed in for a session ID.
SESSION_ID=$(curl -sS -X POST "$BASE_URL/auth/otp/" \
  -H "Authorization: $API_KEY" \
  -H "Content-Type: application/json" \
  -d "{\"user_uuid\": \"$USER_UUID\", \"one_time_password\": \"123456\"}" \
  | jq -r '.bakkt_session_id')

# 3. Initiate KYC (see /docs/kyc-kyb for the full integration paths).
#    Step 3a: Create the KYC applicant — returns 204 No Content.
curl -sS -X POST "$BASE_URL/user/kyc/applicant" \
  -H "Authorization: $API_KEY" \
  -H "bakkt-session-id: $SESSION_ID" \
  -H "Content-Type: application/json" \
  -d '{
    "address": {
      "address_line_1": "123 Main Street",
      "post_code": "SW1A 1AA",
      "city": "London",
      "country": "GB"
    },
    "date_of_birth": "1990-05-15",
    "source_of_funds": "SALARY"
  }'

# Step 3b: Get a Sumsub access token to hand to the Web SDK in your frontend.
KYC_TOKEN=$(curl -sS -X GET "$BASE_URL/user/kyc/applicant/token" \
  -H "Authorization: $API_KEY" \
  -H "bakkt-session-id: $SESSION_ID" \
  | jq -r '.token')

echo "Pass this token to the Sumsub Web SDK: $KYC_TOKEN"

# 4. Monitor status. In production, react to the KYC webhook (see /docs/webhooks).
#    For development, poll GET /user:
curl -sS -X GET "$BASE_URL/user" \
  -H "Authorization: $API_KEY" \
  -H "bakkt-session-id: $SESSION_ID" \
  | jq '{status, user_uuid, email}'

# 5. Once `status` is `FULL_USER` the user can use the rest of the platform
#    (Accounts API, Stablecoin API, etc.).

Next steps