La documentación se publica en inglés.

DocumentationTrust

Verify a checkpoint yourself

Two unauthenticated requests, an ES256 check with any library, and a walk backwards through the chain. No tool of ours required.

What you are about to establish

By the end of this page you will have confirmed, with code you wrote and a library we did not, that a key UnifyID publishes signed a specific 256 bytes committing to a specific Merkle root, and that every statement in the published history joins onto the one before it with no gaps.

That is a real result and a bounded one. Read What this does not yet prove before you quote it to anybody.

Step one: two requests

One for the newest statement, one for the keys. Neither needs a credential.

Fetch the statement and the key set
curl -s "$UNIFYID_API/v1/public/transparency/checkpoints/latest" \
  -o checkpoint.json

curl -s "$UNIFYID_API/.well-known/unifyid-checkpoint-keys.json" \
  -o keys.json

Keep both files. The statement is immutable once sealed, and an archived key set is the only thing that makes a later comparison meaningful.

Step two: three checks, in this order

The order matters, because each one makes the next one mean something. The fingerprint check proves the fields you are reading really are the bytes that were signed. The key check proves the bytes name the key you are about to use. Only then does verifying the signature say anything.

verify-checkpoint.mjs — node:crypto only, no dependencies
import { readFile } from "node:fs/promises";
import { createHash, createPublicKey, verify } from "node:crypto";

const checkpoint = JSON.parse(await readFile("checkpoint.json", "utf8"));
const { keys } = JSON.parse(await readFile("keys.json", "utf8"));

const statement = Buffer.from(checkpoint.statement.slice(2), "hex");
const signature = Buffer.from(checkpoint.signature.slice(2), "hex");
if (statement.length !== 256) throw new Error("not a version 1 statement");
if (signature.length !== 64) throw new Error("not a raw r||s signature");

// 1. The fingerprint IS the digest that was signed.
const fingerprint = createHash("sha256").update(statement).digest("hex");
if (fingerprint !== checkpoint.fingerprint) throw new Error("fingerprint does not match");

// 2. The statement names its own key, in its eighth word. Find that key.
const jwk = keys.find((key) => key.kid === checkpoint.signingKeyId);
if (!jwk) throw new Error("the signing key is not in the published set");

const keyFingerprint = createHash("sha256")
  .update(Buffer.concat([
    Buffer.from([0x04]),
    Buffer.from(jwk.x, "base64url"),
    Buffer.from(jwk.y, "base64url"),
  ]))
  .digest("hex");
if (keyFingerprint !== checkpoint.signingKeyFingerprint)
  throw new Error("the published key is not the key the statement names");
if (keyFingerprint !== statement.subarray(224, 256).toString("hex"))
  throw new Error("the JSON disagrees with the signed bytes");

// 3. That key signed these bytes. ES256 = P-256 + SHA-256, raw r||s.
const publicKey = createPublicKey({ key: jwk, format: "jwk" });
const held = verify("sha256", statement, { key: publicKey, dsaEncoding: "ieee-p1363" }, signature);

console.log(held ? "signature holds" : "SIGNATURE DOES NOT HOLD");

Any ES256 implementation will do this — WebCrypto's crypto.subtle.verify({ name: "ECDSA", hash: "SHA-256" }, key, signature, statement) with the JWK imported directly, Python's cryptography with a P-256 public key, Go's ecdsa.VerifyASN1 after converting r || s. The published encoding is the plain JOSE one precisely so you are not obliged to use ours.

Decoding the eight words, if you want to read them rather than trust the JSON
const word = (index) => statement.subarray(index * 32, (index + 1) * 32);
const uint = (index) => BigInt(`0x${word(index).toString("hex")}`);

const decoded = {
  version: Number(uint(0)),                    // 1
  logId: word(1).toString("hex"),
  sequence: uint(2).toString(),
  periodStart: new Date(Number(uint(3)) * 1000).toISOString(),
  periodEnd: new Date(Number(uint(4)) * 1000).toISOString(),
  combinedRoot: word(5).toString("hex"),
  previousCombinedRoot: word(6).toString("hex"),
  signingKeyFingerprint: word(7).toString("hex"),
};

// Every one of these must equal the matching JSON field. `previousCombinedRoot`
// is 32 zero bytes here and `null` in the JSON when the sequence is 1.

The recorded values, so you can check the check

If you want to confirm the script before pointing it at a live service, feed it these. They were recorded from a running API and they verify.

Checkpoint 2 of a recorded log

sequence
2
combinedRoot
4520f8ffeae3e86e37b1b5e5a0a3042e2fa7e745b25de71cf0e7f4b644d710b0
fingerprint
5d67966ffa2696fc221264bfa3d2efc7ce20603c7f0a2b00d958c41475f33add — and SHA-256 of the 256 statement bytes is exactly this.
signingKeyId
audit-WvDfp1ygnv93KiceJlwn
signingKeyFingerprint
745896659f3fd078ae663119934bc6e2ec5e1fcfc8cac2ef94e6057836aef27a — and SHA-256(0x04 || X || Y) of the published JWK is exactly this.
The eight decoded words of that statement
0  0000000000000000000000000000000000000000000000000000000000000001  version = 1
1  555983597a1cb6517ef9c23949d3370e100c5ca637c833dd37abae204e2d2da6  logId
2  0000000000000000000000000000000000000000000000000000000000000002  sequence = 2
3  000000000000000000000000000000000000000000000000000000006aac9c4c  periodStart = 2026-09-18T02:05:00Z
4  000000000000000000000000000000000000000000000000000000006aacaa5c  periodEnd   = 2026-09-18T03:05:00Z
5  4520f8ffeae3e86e37b1b5e5a0a3042e2fa7e745b25de71cf0e7f4b644d710b0  combinedRoot
6  d2aebc8fb6f20a33a68bdf43ea160b0b88cf92b77136104a9ce75b1d03dabf7a  previousCombinedRoot
7  745896659f3fd078ae663119934bc6e2ec5e1fcfc8cac2ef94e6057836aef27a  signingKeyFingerprint

Word 6 is checkpoint 1's combinedRoot. That is the link, and the next step is to follow it.

Step three: walk the chain backwards

One verified statement tells you about one hour. The history is what tells you nothing was quietly re-cut, and the history is a chain you can walk with three comparisons per step.

walk-back.mjs
const api = process.env.UNIFYID_API;

async function page(before) {
  const url = new URL(`${api}/v1/public/transparency/checkpoints`);
  url.searchParams.set("limit", "200");
  if (before) url.searchParams.set("before", before);
  return fetch(url).then((response) => response.json());
}

const all = [];
let before;
do {
  const { items, nextBefore } = await page(before);   // newest first
  all.push(...items);
  before = nextBefore;
} while (before);

const logId = all[0].logId;

for (let i = 0; i < all.length - 1; i += 1) {
  const later = all[i];
  const earlier = all[i + 1];

  if (later.logId !== logId) throw new Error(`${later.sequence}: a different log`);
  if (BigInt(later.sequence) !== BigInt(earlier.sequence) + 1n)
    throw new Error(`gap before sequence ${later.sequence}`);
  if (later.previousCombinedRoot !== earlier.combinedRoot)
    throw new Error(`root chain broken at sequence ${later.sequence}`);
  if (later.previousFingerprint !== earlier.fingerprint)
    throw new Error(`fingerprint chain broken at sequence ${later.sequence}`);
  if (later.periodStart !== earlier.periodEnd)
    throw new Error(`period gap before sequence ${later.sequence}`);
}

const first = all[all.length - 1];
if (first.sequence !== "1") throw new Error("the history does not reach sequence 1");
if (first.previousCombinedRoot !== null) throw new Error("sequence 1 claims a predecessor");

console.log(`${all.length} checkpoints, unbroken, back to sequence 1`);

Run the signature check from step two over every item as you go and you have verified the whole published history: every statement signed by a published key, every statement joined to its predecessor by two independent hashes, and every period joined end to end with no missing time.

What this does not do

  • Fetching the statement and the key in the same breath proves less than it looks. A service can hand out any matched pair it likes. What makes the check bite is a key set you archived earlier and compare against.
  • `createdAt` is not signed. Only periodStart and periodEnd are inside the statement. Do not build a timing argument on createdAt.
  • A verified statement says nothing about any particular record. It commits to a combined root. Connecting a record to that root needs an inclusion proof — see Proof bundles.
  • A development log signs with an ephemeral key. It verifies correctly and means nothing across a restart. Check signingKeys[].custody in the overview before you attach weight to a log.

Verified against API commit 234dba22 on 2026-10-18. The routes, scopes, checklist, events and limits on these pages are read from that export, not typed.