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:
- 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. - 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.
| Document | Public landing page (UI link) |
|---|---|
| Terms and Conditions (EPMAP) | https://getunblock.com/epmap-terms-of-service |
| Terms of Service | https://getunblock.com/terms-of-service |
| Privacy Policy | https://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.comvswww.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 (fetchandrequestsdo by default;curlneeds-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
POST /user and PATCH /user| Field | Source document |
|---|---|
accepted_terms_and_conditions_hash | EPMAP Terms and Conditions (PDF) |
accepted_terms_of_service_hash | Terms of Service (PDF) |
accepted_privacy_policy_hash | Privacy Policy (PDF) |
The user-side endpoints accept both
accepted_terms_and_conditions_hash(plural) andaccepted_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}
POST /corporate and PATCH /corporate/{corporate_uuid}| Field | Source document |
|---|---|
accepted_terms_and_condition_hash | EPMAP Terms and Conditions (PDF) |
accepted_terms_of_service_hash | Terms of Service (PDF) |
accepted_privacy_policy_hash | Privacy Policy (PDF) |
The corporate-side endpoints use the singular form
accepted_terms_and_condition_hashin 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.
GETthe public landing page URL, following redirects, and read the response body as text.- Extract the canonical PDF URL from the rendered landing page. It's a plain
https://.../*.pdfURL — 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/...). GETthe PDF URL and read the response body as raw bytes.- Compute the SHA-256 digest of those bytes.
- Base64-encode the digest.
- 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.com → www.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:
- Open the public landing page in a browser.
- Right-click the document download link on the rendered page and copy the URL.
- 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:
- 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.
MISSING_TERMS_AND_CONDITIONS_SIGNEDwebhook. 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.
- Event:
See Webhooks → entityStatusUpdate for the full event payload structure.
Recommended workflow
- 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.
- 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_SIGNEDwebhook. - 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. - 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
- User Management → Create a user — where the user-side hashes are sent.
- Corporate Management → Create a corporate — where the corporate-side hashes are sent.
- Webhooks →
entityStatusUpdate— how Bakkt notifies you when a user falls out of T&C compliance.
Updated 1 day ago
