Terms and Conditions Hashes

Overview

When hash validation is enabled for your merchant (see the opt-in callout below), Bakkt requires hashes of the legal documents the end-user has accepted to be sent on every user/corporate create or update call. The hashes prove that a specific version of each document was shown to the user at the moment they consented, and they're retained in Bakkt's compliance records. Merchants who haven't enabled this feature can omit the hash fields entirely.

This guide explains:

  • which fields you need to send and where,
  • how to compute the hashes correctly,
  • how to react when Bakkt re-versions a document and a previously verified user goes out of compliance.

This feature is opt-in per merchant. It is not active by default for all merchants — typically only merchants who collect T&C acceptance in their own UI (rather than relying on the Sumsub-hosted KYC flow) need it. To turn it on for your merchant account, contact your Bakkt account manager.

About the encoding. These fields are SHA-256 digests base64-encoded. Some example values in the OpenAPI spec are hex digests of an empty string (e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855); those are placeholder examples, not real produced hashes. The wire format is base64.

Fields by entity

All three hashes are computed the same way (SHA-256 of the PDF bytes, base64-encoded). Only the field names differ between user and corporate flows.

Individual users — POST /user and PATCH /user

FieldSource document
accepted_terms_and_conditions_hashEPMAP Terms and Conditions (PDF)
accepted_terms_of_service_hashTerms of Service (PDF)
accepted_privacy_policy_hashPrivacy Policy (PDF)

The user-side endpoints accept both accepted_terms_and_conditions_hash (plural) and accepted_terms_and_condition_hash (singular) as aliases. Stick with the plural form for users — it's what the user-facing OpenAPI schema uses.

See User Management → Create a user.

Corporates — POST /corporate and PATCH /corporate/{corporate_uuid}

FieldSource document
accepted_terms_and_condition_hashEPMAP Terms and Conditions (PDF)
accepted_terms_of_service_hashTerms of Service (PDF)
accepted_privacy_policy_hashPrivacy Policy (PDF)

The corporate-side endpoints use the singular form accepted_terms_and_condition_hash in the OpenAPI schema. Match the schema to keep your code unambiguous.

See Corporate Management → Create a corporate.

How to compute a hash

  1. GET the PDF URL and read the response body as raw bytes.
  2. Compute the SHA-256 digest of those bytes.
  3. Base64-encode the digest.
  4. Send that string as the field value.

Node.js

const { createHash } = require('crypto');

async function hashLegalDocument(pdfUrl) {
  const response = await fetch(pdfUrl);
  if (!response.ok) {
    throw new Error(`Failed to fetch ${pdfUrl}: ${response.status}`);
  }
  const pdf = Buffer.from(await response.arrayBuffer());
  return createHash('sha256').update(pdf).digest('base64');
}

async function hashAllLegalDocuments() {
  const [terms, tos, privacy] = await Promise.all([
    hashLegalDocument('https://s3.eu-west-2.amazonaws.com/legal.getunblock.com/EPMAP+T%26Cs+Jan+30+2025.pdf'),
    hashLegalDocument('https://s3.eu-west-2.amazonaws.com/legal.getunblock.com/Complycrypto_Terms_Conditions.pdf'),
    hashLegalDocument('https://s3.eu-west-2.amazonaws.com/legal.getunblock.com/complycrypto_privacy_policy.pdf'),
  ]);
  return {
    accepted_terms_and_conditions_hash: terms,
    accepted_terms_of_service_hash: tos,
    accepted_privacy_policy_hash: privacy,
  };
}

Python

import base64
import hashlib
import requests


def hash_legal_document(pdf_url: str) -> str:
    response = requests.get(pdf_url)
    response.raise_for_status()
    digest = hashlib.sha256(response.content).digest()
    return base64.b64encode(digest).decode("ascii")

terms   = hash_legal_document("https://s3.eu-west-2.amazonaws.com/legal.getunblock.com/EPMAP+T%26Cs+Jan+30+2025.pdf")
tos     = hash_legal_document("https://s3.eu-west-2.amazonaws.com/legal.getunblock.com/Complycrypto_Terms_Conditions.pdf")
privacy = hash_legal_document("https://s3.eu-west-2.amazonaws.com/legal.getunblock.com/complycrypto_privacy_policy.pdf")

Shell

hash_legal_document() {
  pdf_url="$1"
  curl -sSL "$pdf_url" | openssl dgst -sha256 -binary | base64
}

hash_legal_document https://s3.eu-west-2.amazonaws.com/legal.getunblock.com/EPMAP+T%26Cs+Jan+30+2025.pdf
hash_legal_document https://s3.eu-west-2.amazonaws.com/legal.getunblock.com/Complycrypto_Terms_Conditions.pdf
hash_legal_document https://s3.eu-west-2.amazonaws.com/legal.getunblock.com/complycrypto_privacy_policy.pdf

Manual / one-time

For a quick check, or for the very first setup, you can resolve the PDF URL by hand:

  1. Open the public landing page in a browser.
  2. Right-click the document download link on the rendered page and copy the URL.
  3. Run, for example: curl -sSL "<PDF-URL>" | openssl dgst -sha256 -binary | base64.

That gives you the same value as the scripted approach above and lets you sanity-check what the automation produces.

When and how to refresh hashes

Bakkt may publish new versions of any of the three documents. Two signals tell you a refresh is needed:

  1. Advance notice. Bakkt notifies merchants before any document version goes live. Use that window to recompute the hashes and update your acceptance UI ahead of the rollover.
  2. MISSING_TERMS_AND_CONDITIONS_SIGNED webhook. For users who are already verified but now hold acceptances for a superseded document version, Bakkt fires:
    • Event: entityStatusUpdate
    • subType: MISSING_TERMS_AND_CONDITIONS_SIGNED
    • What it means: the user passed KYC but is missing valid hashes for the latest document versions and cannot transact until they re-accept.
    • What to do: re-resolve the PDFs, recompute the hashes, present the documents to the user again, and on acceptance call PATCH /user (or the corporate equivalent) with the new hashes.

See Webhooks → entityStatusUpdate for the full event payload structure.

Recommended workflow

  1. At onboarding time, resolve and hash the three PDFs, present the documents to the user in your UI for explicit acceptance, and only then send the create call. Store the hashes in your own records alongside the user/corporate ID, the resolved PDF URL, and the timestamp of acceptance.
  2. Cache aggressively. The resolved canonical PDF URL and resulting hash for a given document version don't change until Bakkt publishes a new version. Resolve and hash once per app boot (or once per day), cache the result, and only refresh when you receive advance notice or a MISSING_TERMS_AND_CONDITIONS_SIGNED webhook.
  3. On MISSING_TERMS_AND_CONDITIONS_SIGNED, mark the affected user as "needs re-acceptance," gate transactions until they re-accept, and PATCH the new hashes once they do.
  4. Don't share hashes across users. Even though the hash value will be the same for every user accepting the same version, you must collect explicit acceptance from each user — the hash is evidence of consent, not a substitute for collecting it.

Best practices

  • Hash raw bytes, not parsed text. Don't try to extract text from the PDF, normalize whitespace, or convert encodings — SHA-256 over the exact bytes the S3 endpoint returned is the only thing that will match.
  • Store the resolved PDF URL alongside the hash. It's a stable, versioned reference (the version date is in the filename) to the exact document the user accepted.
  • Pin a copy of the PDF on your side. Save the resolved PDF (with its source URL and capture date) so you can show the exact document a user accepted in a compliance audit, even after Bakkt rotates to a newer version.
  • Log the timestamp of acceptance. Bakkt's compliance workflows assume you can produce evidence that a specific user accepted a specific version at a specific moment.
  • Surface re-acceptance prompts immediately when you receive MISSING_TERMS_AND_CONDITIONS_SIGNED. The user is otherwise blocked from transacting and you'll see support traffic.

Next steps


Did this page help you?