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.
curl -s "$UNIFYID_API/v1/public/transparency/checkpoints/latest" \
-o checkpoint.json
curl -s "$UNIFYID_API/.well-known/unifyid-checkpoint-keys.json" \
-o keys.jsonKeep 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.
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.
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
sequence2combinedRoot4520f8ffeae3e86e37b1b5e5a0a3042e2fa7e745b25de71cf0e7f4b644d710b0fingerprint5d67966ffa2696fc221264bfa3d2efc7ce20603c7f0a2b00d958c41475f33add— andSHA-256of the 256 statement bytes is exactly this.signingKeyIdaudit-WvDfp1ygnv93KiceJlwnsigningKeyFingerprint745896659f3fd078ae663119934bc6e2ec5e1fcfc8cac2ef94e6057836aef27a— andSHA-256(0x04 || X || Y)of the published JWK is exactly this.
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 signingKeyFingerprintWord 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.
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
periodStartandperiodEndare inside the statement. Do not build a timing argument oncreatedAt. - 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[].custodyin 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.