Skip to content

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.

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.

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).

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.

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 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:

  1. Take the eight snapshot fields: registry_code, slug, avatar_name, type, scope, operates_at, responsible_name and registered_at.
  2. Normalise the values: type and scope are their lowercase identifiers (e.g. voice_clone); if operates_at was not provided, use the empty string ""; registered_at is formatted as ISO-8601 in UTC with exactly six microsecond digits and a trailing Z: YYYY-MM-DDTHH:MM:SS.ssssssZ.
  3. 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.
  4. Hash it: content_hash is the lowercase hexadecimal SHA-256 of those bytes (64 characters).

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:

#KeyLength
1slug4
2type4
3scope5
4avatar_name11
5operates_at11
6registered_at13
7registry_code13
8responsible_name16

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.

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.

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

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
  • Empty operates_at: a record with no declared location hashes the empty string "" for that field, not null. The snippets above already do this with rec.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). Both json.dumps(..., ensure_ascii=False) and JavaScript’s JSON.stringify match it for typical names and URLs.
  1. Fetch the record from the public API (or read the fields off its public page).
  2. Rebuild the canonical snapshot with the recipe above.
  3. 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.