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_USERAt 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.
| Status | Description | What 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_NEEDED | User exists but has not started KYC | Prompt the user to start KYC (POST /user/kyc/applicant) |
PENDING_KYC_DATA | KYC started but documents are missing | Inspect missing docs with GET /user/kyc/applicant and prompt the user to upload them |
KYC_PENDING | KYC submitted and under review | Wait for the verification webhook |
FULL_USER | KYC approved | Only users in this state can transact |
SOFT_KYC_FAILED | Recoverable failure | Check the rejection reason on the webhook payload, guide the user to fix the issue, and retry |
HARD_KYC_FAILED | Unrecoverable failure | Inform the user; no retry is possible |
SUSPENDED | Compliance hold; user cannot take any action | Contact 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 /userresponse only enumerates the post-creation statuses (KYC_NEEDEDonwards).PHONE_VERIFICATION_NEEDEDis the implicit initial state for U.S. users immediately afterPOST /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:
| Region | Additional required fields | Notes |
|---|---|---|
| Europe | — | Globally-required fields only. |
| U.S. | phone | Triggers the phone verification flow. ssn and ip_address may be required for some users. |
| Nigeria | target_address, bvn | bvn is the Nigerian Bank Verification Number — exactly 11 digits, no dashes or spaces. |
| India, Pakistan | — | Globally-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 CreatedUpdate 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 ContentTarget 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-format0x…). - 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 passtarget_address_vasp(ortarget_solana_address_vaspfor 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 OTP — POST /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 OTP — POST /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
- Authentication — SIWE and Email OTP flows in depth.
- KYC & KYB — verification flow, statuses, and rejection reasons.
- Webhooks — subscribe to status changes instead of polling.
- Corporate management — link users to corporate entities.
POST /user·PATCH /user·GET /user— full reference.- Full API reference — every endpoint, schema, and error code.
Updated 3 days ago
