KYC & KYB Verification

Overview

Bakkt uses Sumsub for identity verification:

  • KYC (Know Your Customer) — required for individual users before they can transact.
  • KYB (Know Your Business) — required for corporate entities before they can transact.

You can integrate verification in three ways:

ApproachWhen to useEndpoints involved
Sumsub Web SDK (recommended)You can host JS in the user's browser and want Sumsub's hosted UI.POST /user/kyc/applicant, GET /user/kyc/applicant/token
Hosted form URLYou only need to send the user a link (e.g. via email) to a hosted form.GET /user/kyc/applicant/url (KYC), GET /corporate/{corporate_uuid}/kyb/applicant/url (KYB)
API-only (server-to-server)You collect documents yourself and submit them via API.POST /user/kyc/applicant, PUT /user/kyc/document, POST /user/kyc/verification

If you already operate your own Sumsub account, you can also import an existing verified KYC applicant into Bakkt using Sumsub's Reusable KYC feature so the user doesn't have to redo verification.

Before you begin

  • Sandbox base URL: https://sandbox.api.bakkt.com
  • Production base URL: https://api.bakkt.com
  • Headers on every call: Authorization: <YOUR_API_KEY> and either bakkt-session-id: <SESSION_ID> or, for select read endpoints, user-uuid: <USER_UUID>. See Authentication.

KYC (individual users)

KYC status lifecycle

The happy path:

KYC_NEEDED → PENDING_KYC_DATA → KYC_PENDING → FULL_USER

KYC_PENDING can also resolve to a failure state:

  • SOFT_KYC_FAILED — recoverable; user can retry, looping back to KYC_PENDING.
  • HARD_KYC_FAILED — terminal; contact support.
StatusMeaningNext action
KYC_NEEDEDUser exists but no KYC application has been created.POST /user/kyc/applicant
PENDING_KYC_DATAApplication created; documents not yet collected.Launch Sumsub SDK or upload via API.
KYC_PENDINGDocuments submitted; Sumsub/compliance is reviewing.Wait for webhook or poll GET /user/kyc/applicant.
SOFT_KYC_FAILEDRecoverable failure (bad image, mismatched data, etc.).User can retry — see Recovering from a soft failure.
HARD_KYC_FAILEDTerminal failure (compliance hit, fraud signal, etc.).Contact support; user cannot self-serve.
FULL_USERKYC approved; user can transact. Reflected on the user object via GET /user.

Note: The FULL_USER status is exposed on the user object (GET /user), not on the KYC applicant response. The KYC applicant endpoint only returns the in-flight statuses above.

Step 1 — Create a KYC application

This is a one-off call per user. It registers the applicant with Sumsub and unlocks the document-collection endpoints.

Endpoint: POST /user/kyc/applicant — returns 204 No Content.

await fetch('https://sandbox.api.bakkt.com/user/kyc/applicant', {
  method: 'POST',
  headers: {
    'Authorization': 'YOUR_API_KEY',
    'bakkt-session-id': sessionId,
    'Content-Type': 'application/json'
  },
  body: JSON.stringify({
    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'
  })
});

source_of_funds values:

ValueDescription
SALARYEmployment income
BUSINESS_INCOMESelf-employed / business revenue
PENSIONRetirement income
OTHEROther legitimate sources

U.S. users require additional fields in the request body: ssn (9 digits), ip_address, phone (E.164). See the API reference for the full schema.

Step 2 — Choose a collection method

Pick one of the three options below.

Option A — Sumsub Web SDK (recommended)

Get a short-lived Sumsub access token and embed Sumsub's hosted widget in your page.

Token endpoint: GET /user/kyc/applicant/token

const tokenRes = await fetch('https://sandbox.api.bakkt.com/user/kyc/applicant/token', {
  headers: {
    'Authorization': 'YOUR_API_KEY',
    'bakkt-session-id': sessionId
  }
});
const { token } = await tokenRes.json();

Then initialise the widget. The Sumsub SDK exposes a global snsWebSdk:

<script src="https://static.sumsub.com/idensic/static/sns-websdk-builder.js"></script>
<div id="sumsub-websdk-container"></div>

<script>
  async function getNewAccessToken() {
    const res = await fetch('https://sandbox.api.bakkt.com/user/kyc/applicant/token', {
      headers: {
        'Authorization': 'YOUR_API_KEY',
        'bakkt-session-id': sessionId
      }
    });
    const { token } = await res.json();
    return token;
  }

  const snsWebSdkInstance = snsWebSdk
    .init(token, () => getNewAccessToken())
    .withConf({ lang: 'en' })
    .withOptions({ addViewportTag: false, adaptIframeHeight: true })
    .on('idCheck.onStepCompleted', (payload) => {
      console.log('Sumsub step completed', payload);
    })
    .on('idCheck.onApplicantStatusChanged', (payload) => {
      console.log('Applicant status changed', payload);
    })
    .on('idCheck.onError', (error) => {
      console.error('Sumsub error', error);
    })
    .build();

  snsWebSdkInstance.launch('#sumsub-websdk-container');
</script>

Once the user finishes the Sumsub flow, the applicant moves to KYC_PENDING automatically. You'll receive a webhook on the final outcome — see Webhooks.

For the full SDK API and configuration options, see the Sumsub Web SDK documentation.

Option B — Hosted form URL

Get a short-lived URL to Sumsub's hosted form. Send it to the user (email, deep link, etc.) and they complete verification without you embedding any SDK.

Endpoint: GET /user/kyc/applicant/url

const res = await fetch('https://sandbox.api.bakkt.com/user/kyc/applicant/url', {
  headers: {
    'Authorization': 'YOUR_API_KEY',
    'bakkt-session-id': sessionId
  }
});
const { url, ttl_in_seconds } = await res.json();

This endpoint also accepts user-uuid instead of bakkt-session-id for server-to-server calls. The URL is short-lived — generate it on demand rather than caching.

Option C — API-only (server-to-server)

Use this if you collect identity documents yourself (no Sumsub UI). You upload each document via API, then explicitly start verification.

Upload endpoint: PUT /user/kyc/document

await fetch('https://sandbox.api.bakkt.com/user/kyc/document', {
  method: 'PUT',
  headers: {
    'Authorization': 'YOUR_API_KEY',
    'bakkt-session-id': sessionId,
    'Content-Type': 'application/json'
  },
  body: JSON.stringify({
    content: base64Image,            // base64-encoded image, < 500 KB
    document_type: 'PASSPORT',
    document_subtype: 'FRONT_SIDE',  // required for ID_CARD or DRIVERS
    country: 'GB'
  })
});

Supported document_type values for upload:

TypeNotes
SELFIEFace photo
PASSPORTFront page only
DRIVERSRequires FRONT_SIDE and BACK_SIDE uploads
ID_CARDRequires FRONT_SIDE and BACK_SIDE uploads
RESIDENCE_PERMITResidence permit
UTILITY_BILLProof of address

After all required documents are uploaded, explicitly start verification:

Endpoint: POST /user/kyc/verification — returns 204 No Content.

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

You only need POST /user/kyc/verification for the API-only flow. When using the Sumsub Web SDK or the hosted form URL, verification is started automatically once Sumsub has all the documents.

Step 3 — Check status

Poll until you receive a webhook (see Webhooks) or call:

Endpoint: GET /user/kyc/applicant

const res = await fetch('https://sandbox.api.bakkt.com/user/kyc/applicant', {
  headers: {
    'Authorization': 'YOUR_API_KEY',
    'bakkt-session-id': sessionId
  }
});
const status = await res.json();

Example response:

{
  "status": "KYC_PENDING",
  "docs_missing": [
    { "doc_type": "ID_CARD" },
    { "doc_type": "SELFIE" }
  ],
  "kyc_enduser_error_message": "Please provide a clear photo of your ID card",
  "kyc_reject_label": []
}

To know whether the user is fully verified, check status === 'FULL_USER' on GET /user.

Recovering from a soft failure

When a user lands in SOFT_KYC_FAILED, they can retry. Use the same path you used originally:

  • If you used Sumsub SDK or hosted URL: request a fresh token (GET /user/kyc/applicant/token) or a fresh URL (GET /user/kyc/applicant/url) and re-launch the flow. The user updates the failing documents and Sumsub re-runs verification.
  • If you used the API-only flow: re-upload the failing documents with PUT /user/kyc/document, then call POST /user/kyc/verification again.

The kyc_reject_label array on GET /user/kyc/applicant tells you exactly which checks failed — surface those to the user before retry.

Import an existing KYC applicant (Sumsub Reusable KYC)

If you already operate your own Sumsub account and have a verified applicant for this user there, you can import it into Bakkt's Sumsub instance instead of re-collecting documents. This uses Sumsub's Reusable KYC feature.

How it works:

  1. In your Sumsub account, generate a share token for the verified applicant.
  2. Send that token to Bakkt via the endpoint below — Bakkt's Sumsub instance ingests the verification.

Endpoint: POST /user/kyc/applicant/share-token — returns 204 No Content.

await fetch('https://sandbox.api.bakkt.com/user/kyc/applicant/share-token', {
  method: 'POST',
  headers: {
    'Authorization': 'YOUR_API_KEY',
    'bakkt-session-id': sessionId,
    'Content-Type': 'application/json'
  },
  body: JSON.stringify({
    share_token: 'sumsub-share-token-from-your-account',
    source_of_funds: 'SALARY'
  })
});

This requires prior coordination with Bakkt support to enable cross-account KYC sharing in Sumsub between your Sumsub account and Bakkt's. Tokens generated against an unrelated Sumsub account will not be accepted.

Non-document ID verification (U.S. only)

For U.S. users who cannot or do not want to upload identity documents, Bakkt supports a non-document verification flow that uses identity-data lookups (name, DOB, SSN, address) instead of physical documents.

  • Available only for U.S. users.
  • Requires ssn, ip_address, and phone in the create-applicant payload (the same U.S.-required fields as the document flow).
  • Contact Bakkt support to enable this flow on your merchant account.

KYB (corporate entities)

KYB status lifecycle

The happy path:

CREATED → PENDING_KYB_DATA → KYB_PENDING → ACTIVE

KYB_PENDING can also resolve to a failure state:

  • SOFT_KYB_FAILED — recoverable; the corporate can resubmit, looping back to KYB_PENDING.
  • HARD_KYB_FAILED — terminal; contact support.
StatusMeaningNext action
CREATEDCorporate exists; KYB form not yet generated.GET /corporate/{corporate_uuid}/kyb/applicant/url
PENDING_KYB_DATAKYB form generated; corporate has not submitted yet.Send the form URL to the corporate admin.
KYB_PENDINGKYB submitted; under compliance review.Wait for webhook or poll GET /corporate/{corporate_uuid}.
SOFT_KYB_FAILEDRecoverable failure (e.g. missing document).Re-issue the form URL and have the corporate resubmit.
HARD_KYB_FAILEDTerminal failure.Contact support.
ACTIVEKYB approved; corporate can transact.

Step 1 — Get the KYB form URL

After creating the corporate (User Management → Create a corporate), generate a hosted KYB form URL.

Endpoint: GET /corporate/{corporate_uuid}/kyb/applicant/url

const res = await fetch(
  `https://sandbox.api.bakkt.com/corporate/${corporateUuid}/kyb/applicant/url`,
  {
    headers: {
      'Authorization': 'YOUR_API_KEY',
      'bakkt-session-id': sessionId
    }
  }
);
const { url } = await res.json();

// Send this URL to the corporate admin
sendEmail(adminEmail, {
  subject: 'Complete your KYB verification',
  body: `Please complete verification: ${url}`
});

Step 2 — Corporate admin completes the form

The admin completes the Sumsub-hosted form externally. The form collects:

  • Company documents (certificate of incorporation, articles of association)
  • Beneficial-ownership information (anyone with > 25 % ownership)
  • Business activity and geographic operations
  • Financial information and expected transaction volumes
  • Compliance questionnaire

Step 3 — Monitor KYB status

Wait for the KYB webhook or poll the corporate object:

const res = await fetch(
  `https://sandbox.api.bakkt.com/corporate/${corporateUuid}`,
  {
    headers: {
      'Authorization': 'YOUR_API_KEY',
      'bakkt-session-id': sessionId
    }
  }
);
const corporate = await res.json();

if (corporate.status === 'ACTIVE') {
  console.log('KYB approved — corporate can transact');
} else if (corporate.status === 'HARD_KYB_FAILED') {
  console.log('KYB rejected — contact support');
}

Document requirements by country

Use the reference endpoint to discover which documents are accepted in a given country.

Endpoint: GET /kyc-documents/ — optional country_code query parameter.

const res = await fetch(
  'https://sandbox.api.bakkt.com/kyc-documents/?country_code=GB',
  { headers: { 'Authorization': 'YOUR_API_KEY' } }
);
const docs = await res.json();
console.log('UK documents:', docs.GB);
// {
//   "passport": "FRONT",
//   "id_card": "FRONT_AND_BACK",
//   "driving_license": "FRONT_AND_BACK"
// }

The response is always keyed by country code (e.g. docs.GB, docs.NG), even when you filter by a single country. Without country_code, you get the full map.

Page-requirement values:

ValueMeaning
FRONTFront page only
FRONT_AND_BACKBoth sides required
NOT_SUPPORTEDDocument type not accepted in that country

Building dynamic UI from requirements

function getAcceptedDocuments(countryCode, docReqs) {
  const docs = docReqs[countryCode] ?? {};
  return Object.entries(docs)
    .filter(([, requirement]) => requirement !== 'NOT_SUPPORTED')
    .map(([type, pages]) => ({ type, pages }));
}

const ukDocs = getAcceptedDocuments('GB', kycRequirements);
// [
//   { type: 'passport', pages: 'FRONT' },
//   { type: 'id_card', pages: 'FRONT_AND_BACK' },
//   { type: 'driving_license', pages: 'FRONT_AND_BACK' }
// ]

Sandbox testing

The sandbox lets you simulate any KYC/KYB outcome without going through real verification.

Fast-track KYC

Endpoint: PATCH /user/kyc/verification — sandbox only.

curl -X PATCH https://sandbox.api.bakkt.com/user/kyc/verification \
  -H "Authorization: YOUR_API_KEY" \
  -H "bakkt-session-id: SESSION_ID" \
  -H "Content-Type: application/json" \
  -d '{ "status": "FULL_USER" }'

You can set any status from the lifecycle table above (KYC_NEEDED, PENDING_KYC_DATA, KYC_PENDING, SOFT_KYC_FAILED, HARD_KYC_FAILED, FULL_USER) and an optional rejection_reason for the failure statuses. See the API reference for the full rejection_reason enum.

Fast-track KYB

Endpoint: PATCH /corporate/{corporate_uuid}/kyb/verification — sandbox only.

curl -X PATCH https://sandbox.api.bakkt.com/corporate/$CORPORATE_UUID/kyb/verification \
  -H "Authorization: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "status": "ACTIVE" }'

Sandbox status note: the sandbox PATCH endpoint accepts a legacy REJECTED value alongside CREATED, KYB_PENDING, and ACTIVE. In production the terminal failure statuses are SOFT_KYB_FAILED and HARD_KYB_FAILED (see lifecycle table above). Use HARD_KYB_FAILED semantics when designing your code paths; treat REJECTED only as a sandbox shortcut.

Best practices

Most common verification issues

  • Same document used for ID and proof of address. The PoA must be a different document than the one used for identity verification.
  • Screenshot submitted instead of a photo. Screenshots are not accepted.
    • IDs: clear photos only — no scans.
    • PoA: clear photos or PDF files.
  • PoA document is outdated. Most PoA documents must be issued within the last 6 months. Mobile-operator documents are accepted up to 3 months old.
  • Document image is cropped. All four corners of the document must be visible, even if they contain no information.
  • Address mismatch. The address on the PoA must exactly match the address provided by the user. Either upload a corrected document or update the user's profile via PATCH /user.

Document quality checklist

  • Images are clear and well lit.
  • All four corners of the document are visible.
  • No glare or shadows.
  • Text is readable.
  • Documents are not expired.
  • Selfies show a clear face — no sunglasses, hats, or masks.

Privacy and data handling

  • KYC/KYB documents are stored by Sumsub, Bakkt's verification provider.
  • Documents are encrypted at rest and in transit.
  • Access is logged and audited; retention follows applicable regulatory requirements.
  • Users have a right to data deletion under GDPR and equivalent regimes.

Rejection reasons

When KYC fails, kyc_reject_label on GET /user/kyc/applicant contains one or more codes describing why. Surface these to the user so they know how to fix the issue.

Document quality

CodeMeaning
LOW_QUALITYImage too blurry or pixelated.
INCOMPLETE_DOCUMENTPart of document cut off.
SCANDocument was scanned (not allowed).
DOCUMENT_DAMAGEDDocument is torn or damaged.
UNSATISFACTORY_PHOTOSOverall poor image quality.
SCREENSHOTSScreenshot instead of photo.
BLACK_AND_WHITEColor photo required.

How to fix: retake in good lighting, with all corners visible, in color, on a flat surface — no editing.

Identity-data mismatch

CodeMeaning
DATA_MISMATCHUser-entered data doesn't match the document. Often the address from the PoA.
DOB_DATA_FORMAT_MISMATCHDate-of-birth format mismatch.
FULL_NAME_ADDRESS_DATA_MISSING_OR_MISMATCHName or address missing or doesn't match.
DATA_NOT_FOUND_IN_SOURCEData could not be verified against external sources.

How to fix: correct the user's profile via PATCH /user so the entered data matches the documents exactly, or have the user resubmit a document that matches.

Document validity

CodeMeaning
MISSING_PAGE / DOCUMENT_PAGE_MISSINGA required page is missing.
DOCUMENT_EXPIREDDocument is past its expiry date.
DOCUMENT_INVALIDDocument type or format not recognised.
FORGERYTampering detected.
GRAPHIC_EDITORImage was edited / manipulated.
NOT_DOCUMENTThe upload is not actually a document.
DOCUMENT_DEPRIVED / DOCUMENT_DAMAGEDDocument missing or damaged.

How to fix: upload an original, unedited photo of a current, undamaged document of the correct type.

Selfie issues

CodeMeaning
BAD_SELFIESelfie quality issues.
BAD_VIDEO_SELFIEVideo-selfie problems.
BAD_FACE_MATCHING / SELFIE_MISMATCHFace does not match the ID.
SELFIE_WITH_PAPERUser holding paper / extra object in selfie.

How to fix: retake in good lighting, looking directly at the camera, with no glasses, hats, or masks.

Compliance

CodeMeaning
PEPPolitically Exposed Person.
ADVERSE_MEDIANegative media coverage.
SANCTIONSOn a sanctions list.
CRIMINALCriminal-record flags.
BLACKLIST / BLOCKLISTOn a compliance blacklist.
REGULATIONS_VIOLATIONSRegulatory violations.

How to fix: these are typically HARD_KYC_FAILED outcomes and require contacting support; some may allow enhanced due diligence.

Profile-data mismatches

CodeMeaning
INCONSISTENT_PROFILEProfile data doesn't match documents.
WRONG_ADDRESSAddress verification failed.
REQUESTED_DATA_MISMATCHSubmitted data doesn't match the request.
PROBLEMATIC_APPLICANT_DATAData quality issues.

How to fix: ensure profile fields match the ID exactly; verify address with a recent utility bill.

For the full enum of rejection reasons, see the API reference.

Soft vs. hard failures

  • SOFT_KYC_FAILED / SOFT_KYB_FAILED — recoverable. The user can resubmit corrected documents or data.
  • HARD_KYC_FAILED / HARD_KYB_FAILED — terminal. The user cannot self-serve; merchants should route them to support.

KYB verification requirements

Required documents

Corporate documentation typically required:

  • Certificate of Incorporation
  • Memorandum and Articles of Association
  • Register of Directors
  • Register of Shareholders
  • Proof of business address
  • Bank statements (last 3 months)

The exact list varies by jurisdiction and business type — Sumsub's hosted form drives the user through the country-specific requirements.

Beneficial ownership

For every individual with > 25 % ownership, the form collects:

  • Full name
  • Date of birth
  • Nationality
  • Address
  • ID document
  • Percentage ownership

Business information

  • Nature of business
  • Expected transaction volumes
  • Source of funds
  • Customer base
  • Geographic operations

End-to-end KYC example

async function completeKYC(sessionId) {
  await fetch('https://sandbox.api.bakkt.com/user/kyc/applicant', {
    method: 'POST',
    headers: {
      'Authorization': API_KEY,
      'bakkt-session-id': sessionId,
      'Content-Type': 'application/json'
    },
    body: JSON.stringify({
      address: {
        address_line_1: '123 Main St',
        post_code: 'SW1A 1AA',
        city: 'London',
        country: 'GB'
      },
      date_of_birth: '1990-05-15',
      source_of_funds: 'SALARY'
    })
  });

  const tokenRes = await fetch(
    'https://sandbox.api.bakkt.com/user/kyc/applicant/token',
    {
      headers: {
        'Authorization': API_KEY,
        'bakkt-session-id': sessionId
      }
    }
  );
  const { token } = await tokenRes.json();

  const sdk = snsWebSdk
    .init(token, async () => {
      const r = await fetch(
        'https://sandbox.api.bakkt.com/user/kyc/applicant/token',
        { headers: { 'Authorization': API_KEY, 'bakkt-session-id': sessionId } }
      );
      return (await r.json()).token;
    })
    .withConf({ lang: 'en' })
    .on('idCheck.onError', (err) => console.error('Sumsub error', err))
    .build();

  sdk.launch('#kyc-container');

  const interval = setInterval(async () => {
    const userRes = await fetch('https://sandbox.api.bakkt.com/user', {
      headers: { 'Authorization': API_KEY, 'bakkt-session-id': sessionId }
    });
    const user = await userRes.json();

    if (user.status === 'FULL_USER') {
      console.log('KYC approved');
      clearInterval(interval);
    } else if (user.status === 'HARD_KYC_FAILED') {
      console.log('KYC failed — contact support');
      clearInterval(interval);
    }
  }, 10_000);
}

In production, prefer Webhooks over polling — they fire as soon as the status changes.

Next steps