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 (the API validates against the PDF itself, not against the public-facing landing page that points to it),
  • 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.

The two URLs you need to know about

Each legal document has two URLs in play, and you need to use the right one for the right purpose:

  1. A public landing page under getunblock.com. This is what you link to in your UI when you show the document to the user. The page is a Webflow-rendered HTML page that contains a link to the canonical PDF.
  2. A canonical PDF that the landing page links to. This is what the API validates the hash against. The PDF URL is versioned in its filename (e.g. Permanent+Privacy+policy+Dec+31%2C+2025.pdf), so the URL itself changes when a new version is published.
DocumentPublic landing page (UI link)
Terms and Conditions (EPMAP)https://getunblock.com/epmap-terms-of-service
Terms of Servicehttps://getunblock.com/terms-of-service
Privacy Policyhttps://www.getunblock.com/ub-privacy-and-policy

The PDFs themselves are hosted on different backends — two of them on S3 (s3.eu-west-2.amazonaws.com/legal.getunblock.com/...), and the EPMAP T&C on Webflow's CDN (cdn.prod.website-files.com/...). You don't need to know which is which; the extractor below scans the landing page for any .pdf URL.

Common mistake — don't hash the landing page. A naive fetch(landingPageUrl).text() followed by hashing produces a deterministic value, but it's a fingerprint of the landing page HTML, not the PDF. The API validates against the PDF and will reject the wrong-thing hash. The DTR documentation historically showed code that did this; it's wrong. Always resolve to the PDF first.

Note on getunblock.com vs www.getunblock.com. The non-www host issues an HTTP 301 to www for two of the three landing pages. Make sure your HTTP client follows redirects (fetch and requests do by default; curl needs -L).

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

The procedure has two stages: resolve the canonical PDF URL, then hash the PDF bytes.

  1. GET the public landing page URL, following redirects, and read the response body as text.
  2. Extract the canonical PDF URL from the rendered landing page. It's a plain https://.../*.pdf URL — for the privacy policy and ToS it lives on S3 (s3.eu-west-2.amazonaws.com/legal.getunblock.com/...), and for the EPMAP T&C it lives on Webflow's CDN (cdn.prod.website-files.com/...).
  3. GET the PDF URL and read the response body as raw bytes.
  4. Compute the SHA-256 digest of those bytes.
  5. Base64-encode the digest.
  6. Send that string as the field value.

Once you've resolved the PDF URL once, it's stable until the next version is published, so you can also cache it (or hardcode it for a release) and skip stage 1 on subsequent runs.

Node.js

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

const PDF_URL_RE = /https?:\/\/[^"'\s)<>]+\.pdf[^"'\s)<>]*/i;

async function resolvePdfUrl(landingPageUrl) {
  const html = await (await fetch(landingPageUrl)).text();
  const match = html.match(PDF_URL_RE);
  if (!match) {
    throw new Error(`No PDF URL found in ${landingPageUrl}`);
  }
  return match[0];
}

async function hashLegalDocument(landingPageUrl) {
  const pdfUrl = await resolvePdfUrl(landingPageUrl);
  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://getunblock.com/epmap-terms-of-service'),
    hashLegalDocument('https://getunblock.com/terms-of-service'),
    hashLegalDocument('https://www.getunblock.com/ub-privacy-and-policy'),
  ]);
  return {
    accepted_terms_and_conditions_hash: terms,
    accepted_terms_of_service_hash: tos,
    accepted_privacy_policy_hash: privacy,
  };
}

Python

import base64
import hashlib
import re
import requests

PDF_URL_RE = re.compile(r"https?://[^\"'\s)<>]+\.pdf[^\"'\s)<>]*", re.IGNORECASE)

def resolve_pdf_url(landing_page_url: str) -> str:
    html = requests.get(landing_page_url).text
    match = PDF_URL_RE.search(html)
    if not match:
        raise RuntimeError(f"No PDF URL found in {landing_page_url}")
    return match.group(0)

def hash_legal_document(landing_page_url: str) -> str:
    pdf_url = resolve_pdf_url(landing_page_url)
    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://getunblock.com/epmap-terms-of-service")
tos     = hash_legal_document("https://getunblock.com/terms-of-service")
privacy = hash_legal_document("https://www.getunblock.com/ub-privacy-and-policy")

Shell

hash_legal_document() {
  landing_page_url="$1"
  pdf_url=$(curl -sSL "$landing_page_url" \
    | grep -oE 'https?://[^"'\'' )<>]+\.pdf[^"'\'' )<>]*' \
    | head -n1)
  if [ -z "$pdf_url" ]; then
    echo "No PDF URL found in $landing_page_url" >&2
    return 1
  fi
  curl -sSL "$pdf_url" | openssl dgst -sha256 -binary | base64
}

hash_legal_document https://getunblock.com/epmap-terms-of-service
hash_legal_document https://getunblock.com/terms-of-service
hash_legal_document https://www.getunblock.com/ub-privacy-and-policy

-L makes curl follow the getunblock.comwww.getunblock.com HTTP 301. grep -oE extracts any .pdf URL from the rendered landing page (the PDFs are spread across S3 and Webflow's CDN, so the regex doesn't pin a specific host). The second curl fetches the PDF bytes, and openssl dgst -sha256 -binary | base64 produces the base64 SHA-256 digest. The output is the value to send.

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 the PDF, not the landing page. The API validates against the PDF bytes; hashing the landing-page HTML will be rejected. See Common mistake above.
  • 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.
  • Be defensive about the regex. If Bakkt ever changes the wrapper-page format or the S3 URL pattern, the extractor in your code will fail to find a match and your refresh job will start throwing. Alert on that explicitly rather than silently falling back to a stale hash.

Next steps