Records
A record is created when you register: dated, numbered and sealed with a cryptographic hash that anyone can recompute. Registering keeps it private: only once you request verification does the record become a public entry in the directory. This page is the full reference, including the exact hash recipe and how to verify a record in three different languages.
What a record contains
Section titled “What a record contains”These are the public fields of a verified record:
| Field | Meaning |
|---|---|
registry_code |
The THB number, e.g. THB-2026-00001. |
slug |
The identifier in the record’s public URL: https://platform.thehumanbehind.com/r/laia. |
avatar_name |
The name of the registered avatar, voice, agent or AI creation. |
type |
avatar · voice_clone · agent · image · video · music. |
scope |
general, or a sensitive scope: health, finance, legal. |
operates_at |
Where it operates: the declared profile, site or channel. |
responsible_name |
The name of the person who answers for it. |
status |
active, or unclaimed (a record awaiting its owner, see Claims). |
verification_level |
registered (green seal) or verified (gold seal, with verified_at). See Verification. |
registered_at |
Date and time of registration, ISO-8601 in UTC. |
content_hash |
SHA-256 of the registration snapshot, computed with the recipe below. |
The responsible person’s email address is not a field of the record. It is never part of the hash, never shown on the public page and never returned by the API. Structurally, not just by policy: the public surface reads from a database view that does not contain it.
Field reference
Section titled “Field reference”A closer look at the fields whose values are constrained:
| Field | Allowed values and rules |
|---|---|
type |
Exactly one lowercase identifier: avatar (a digital character or presenter), voice_clone (a synthetic voice), agent (an autonomous AI acting for someone), image (a generated still), video (a generated video) or music (a music track). |
scope |
general by default. The sensitive scopes health, finance and legal flag an avatar that operates in a regulated, higher-stakes domain. |
slug |
Lowercase letters, digits and hyphens, derived from the avatar name. Assigned once and immutable. If the base is taken, a numeric suffix (-2, -3…) is appended. |
operates_at |
A URL or handle, or empty. When empty, it is treated as the empty string "" in the hash (this matters when you verify). |
The THB number
Section titled “The THB number”Every record gets an identifier in the format THB-<year>-<number>,
e.g. THB-2026-00001: the year of registration
plus a sequential number within that year, zero-padded to a minimum of five digits. It is
assigned atomically by the database at the moment of insertion, it is
gapless within each year and it is never
reused, not even if the record is later unpublished. Two records can
never share a number, and the counter never runs backwards, so the number is
itself a coarse proof of registration order.
What can change, and what never can
Section titled “What can change, and what never can”| Editable by the owner | Immutable for everyone (owner and admins) |
|---|---|
avatar_name, type, scope, operates_at, responsible_name. |
registry_code, registered_at, slug. |
Database triggers reject any attempt to change an immutable field, whether it comes from the owner or from our own administrators. Your number, your date and your slug are yours from the first second and nobody can move them.
The content_hash works differently, and the difference matters when
you verify. It always describes the record as it stands today: edit a
descriptive field and the registry recomputes it, so a recompute from the
current values always matches. What preserves the past is not the hash but the
ledger underneath it: every version a record has ever had is archived
permanently, with its own hash and its own timestamp, in an append-only table
that nobody can rewrite or delete, ourselves included. An edit adds a version,
it never replaces one, and the whole ledger is anchored daily to Bitcoin. So the
registry does not keep one frozen snapshot: it keeps all of them.
Unpublishing is “soft” too: the record stops being listed, but its URL keeps
answering with a dated notice (HTTP 410) so the trace remains.
The content hash: the exact public recipe
Section titled “The content hash: the exact public recipe”The content_hash is a SHA-256 digest computed
by the database itself, in the same transaction that creates the record.
Anyone can recompute it from public data. The recipe, byte by byte:
- Take the eight snapshot fields:
registry_code,slug,avatar_name,type,scope,operates_at,responsible_nameandregistered_at. - Normalise the values:
typeandscopeare their lowercase identifiers (e.g.voice_clone); ifoperates_atwas not provided, use the empty string"";registered_atis formatted as ISO-8601 in UTC with exactly six microsecond digits and a trailingZ:YYYY-MM-DDTHH:MM:SS.ssssssZ. - Serialise as a single-line JSON object in canonical
form: keys sorted by length first, then by byte order (this is the
canonical text form of PostgreSQL’s
jsonb). For these eight fields the order is therefore always:slug,type,scope,avatar_name,operates_at,registered_at,registry_code,responsible_name. A colon-space (:) follows each key and a comma-space (,) separates pairs. Encode the result as UTF-8. - Hash it:
content_hashis the lowercase hexadecimal SHA-256 of those bytes (64 characters).
The canonical key order, explained
Section titled “The canonical key order, explained”Step 3 is the only subtle part. PostgreSQL orders jsonb keys
first by their length in bytes, and then, among keys of equal length, by
byte value. The eight snapshot keys are all ASCII, so byte value is just
alphabetical. Here is the sort applied to our eight keys:
| # | Key | Length |
|---|---|---|
| 1 | slug | 4 |
| 2 | type | 4 |
| 3 | scope | 5 |
| 4 | avatar_name | 11 |
| 5 | operates_at | 11 |
| 6 | registered_at | 13 |
| 7 | registry_code | 13 |
| 8 | responsible_name | 16 |
Because these eight keys never change, this order is fixed forever: you can
hardcode it. Only slug/type (both length 4) and
avatar_name/operates_at (both length 11) and
registered_at/registry_code (both length 13) needed
the tiebreak, and in each pair the alphabetically smaller key comes first.
Worked example
Section titled “Worked example”For a record named Laia
(THB-2026-00001), the canonical snapshot is
this exact single line:
{"slug": "laia", "type": "avatar", "scope": "general", "avatar_name": "Laia", "operates_at": "", "registered_at": "2026-07-30T23:44:44.547749Z", "registry_code": "THB-2026-00001", "responsible_name": "AIGiner S.L."}
Verify it on any machine with a shell:
printf '%s' '{"slug": "laia", "type": "avatar", "scope": "general", "avatar_name": "Laia", "operates_at": "", "registered_at": "2026-07-30T23:44:44.547749Z", "registry_code": "THB-2026-00001", "responsible_name": "AIGiner S.L."}' | sha256sum
# 23ec5dae1e4b330a1ebe266db49bf2d0ca88b7bb6fff0e0eeca1106bbbc5fb6d
The output matches the record’s published content_hash. Change
a single character anywhere in the snapshot and the hash becomes completely
different. That is what makes the record tamper-evident.
Verify in Python
Section titled “Verify in Python”This snippet fetches the record from the public API, rebuilds the canonical snapshot with the recipe above, and compares. Standard library only:
import json, hashlib, urllib.request
SLUG = "laia"
url = f"https://platform.thehumanbehind.com/api/v0/records/{SLUG}.json"
# Con User-Agent propio: el de urllib por defecto ("Python-urllib/3.x") lo
# rechaza el Browser Integrity Check de Cloudflare con un 403. Cualquier
# cadena vale; identificarse es lo educado.
req = urllib.request.Request(url, headers={"User-Agent": "thb-verify/1.0"})
rec = json.load(urllib.request.urlopen(req))
# The eight snapshot fields; a missing operates_at becomes "".
fields = ["registry_code", "slug", "avatar_name", "type",
"scope", "operates_at", "responsible_name", "registered_at"]
snapshot = {k: (rec.get(k) or "") for k in fields}
# The timestamp is canonicalised before hashing: the registry seals
# "...862834Z", while the API returns the raw value "...862834+00:00".
# Skip this line and the digest will not match.
snapshot["registered_at"] = snapshot["registered_at"].replace("+00:00", "Z")
# Canonical form = PostgreSQL jsonb text: keys sorted by (length, then bytes),
# ": " after each key and ", " between pairs.
ordered = dict(sorted(snapshot.items(), key=lambda kv: (len(kv[0]), kv[0])))
canonical = json.dumps(ordered, separators=(", ", ": "), ensure_ascii=False)
digest = hashlib.sha256(canonical.encode("utf-8")).hexdigest()
print(digest == rec["content_hash"]) # True
Verify in Node
Section titled “Verify in Node”The same check in Node 18+ (global fetch, node:crypto):
import { createHash } from "node:crypto";
const SLUG = "laia";
const res = await fetch(
`https://platform.thehumanbehind.com/api/v0/records/${SLUG}.json`,
);
const rec = await res.json();
// The eight snapshot fields; a missing operates_at becomes "".
const fields = ["registry_code", "slug", "avatar_name", "type",
"scope", "operates_at", "responsible_name", "registered_at"];
const snapshot = {};
for (const k of fields) snapshot[k] = rec[k] ?? "";
// The timestamp is canonicalised before hashing: the registry seals
// "...862834Z", while the API returns the raw value "...862834+00:00".
// Skip this line and the digest will not match.
snapshot.registered_at = snapshot.registered_at.replace("+00:00", "Z");
// Canonical form = PostgreSQL jsonb text: keys sorted by length then bytes.
const keys = Object.keys(snapshot).sort(
(a, b) => a.length - b.length || (a < b ? -1 : a > b ? 1 : 0),
);
const canonical =
"{" +
keys.map((k) => JSON.stringify(k) + ": " + JSON.stringify(snapshot[k])).join(", ") +
"}";
const digest = createHash("sha256").update(canonical, "utf8").digest("hex");
console.log(digest === rec.content_hash); // true
Edge cases worth knowing
Section titled “Edge cases worth knowing”- Empty
operates_at: a record with no declared location hashes the empty string""for that field, notnull. The snippets above already do this withrec.get(k) or ""/rec[k] ?? "". - Edited records: the hash seals the day-one values. If an editable field was changed since registration, a recompute from the current values will not match; that is expected. To confirm a historical claim, verify against the originally registered values.
- Non-ASCII values: the canonical form is UTF-8 and uses
the same JSON escaping as PostgreSQL’s
jsonb(literal characters, no unnecessary escapes). Bothjson.dumps(..., ensure_ascii=False)and JavaScript’sJSON.stringifymatch it for typical names and URLs.
Verifying a record end to end
Section titled “Verifying a record end to end”- Fetch the record from the public API (or read the fields off its public page).
- Rebuild the canonical snapshot with the recipe above.
- Compare your SHA-256 with the published
content_hash. If they match, the data you are looking at is exactly what was registered, on the date stated.