Skip to main content

Hash Chain

The HashChainEntry resource is the per-organization SHA-256 hash chain that anchors every DecisionTrace. The chain is append-only (Cardinal Rule 5), per-org-isolated (Cardinal Rule 1), and replay-verifiable offline against a published algorithm. All endpoints on this page require the hashChainAudit feature gate (Governance, Enterprise, or Custom plan tier) and a valid Authorization: Bearer JWT. Tested against API version v1.

The HashChainEntry object

FieldTypeNullableDescription
idstringnoMongoDB ObjectId, also exposed as the _id alias
organizationIdstringnoOwning org's ObjectId; chains never mix across tenants
sequencenumbernoMonotonic per-org counter, starting at 1
traceIdstringnoForeign key to the DecisionTrace this entry covers
prevHashstring (64 hex)noPrevious entry's chainHash; GENESIS_HASH (sixty-four zeros) for the first entry
payloadDigeststring (64 hex)nosha256(canonicalJson(traceView)) — also exposed as payloadHash alias
chainHashstring (64 hex)nosha256(prevHash || payloadDigest || sequence || createdAt)
createdAtstring (ISO 8601)noAppend timestamp; immutable
merkleRootstring (64 hex)yesReserved for Phase-1 Merkle anchor roll-up (Q3 2026); null today
merkleLeafIndexnumberyesReserved for Phase-1; null today
merkleAnchorIdstringyesReserved for Phase-1; null today
eidasTimestampobject | nullyesReserved for eIDAS qualified timestamp (Phase 1); null today

The eidasTimestamp object, when present, will carry value (RFC 3161 token bytes), issuer (TSA name), issuedAt, and serial.

Endpoints

GET   /api/v1/hash-chain/status
POST /api/v1/hash-chain/verify
GET /api/v1/hash-chain/entry/:traceId
GET /api/v1/hash-chain/entries
GET /api/v1/hash-chain/entries/:id
GET /api/v1/hash-chain/anchors
GET /api/v1/hash-chain/anchors/:anchorId/proof
GET /api/v1/hash-chain/export
GET /api/v1/hash-chain/bundle

All endpoints are read-only. The chain is append-only by construction; no mutating operations exist on this resource and Idempotency-Key is therefore not applicable. Per-key rate limits apply per Rate Limits.

Get chain status

Returns headline statistics plus the most recent cached verification result. Suitable for a dashboard KPI; does not re-replay the chain.

GET /api/v1/hash-chain/status
curl
curl https://api.adjudon.com/api/v1/hash-chain/status \
-H "Authorization: Bearer $ADJUDON_API_KEY"

Response (200 OK):

{
"success": true,
"data": {
"totalEntries": 17493,
"lastSequence": 17493,
"lastChainHash": "f1b0...2dde",
"lastEntryAt": "2026-05-06T10:14:22.317Z",
"anchorCount": 0,
"lastAnchorAt": null,
"lastVerifiedAt": "2026-05-06T08:00:00.000Z",
"lastVerificationOk": true,
"algorithm": "sha256",
"canonicalization": "rfc8785",
"appendLatencyP95Ms": 4.2,
"appendLatency": { "p50Ms": 2.1, "p95Ms": 4.2, "p99Ms": 7.8 }
}
}

Errors: 401 (no token), 403 (plan gate), 500 HASH_CHAIN_STATUS_FAILED.

Verify the chain

Re-replays the entire chain from sequence 1 forward. Recomputes each chainHash from prevHash + payloadDigest + sequence + createdAt, compares to the stored value, walks forward.

POST /api/v1/hash-chain/verify
curl
curl -X POST https://api.adjudon.com/api/v1/hash-chain/verify \
-H "Authorization: Bearer $ADJUDON_API_KEY"
Python
import requests, os
r = requests.post(
"https://api.adjudon.com/api/v1/hash-chain/verify",
headers={"Authorization": f"Bearer {os.environ['ADJUDON_API_KEY']}"},
)
r.raise_for_status()
print(r.json())
Node
const res = await fetch(
"https://api.adjudon.com/api/v1/hash-chain/verify",
{
method: "POST",
headers: { Authorization: `Bearer ${process.env.ADJUDON_API_KEY}` },
}
);
const data = await res.json();
Java
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create("https://api.adjudon.com/api/v1/hash-chain/verify"))
.header("Authorization", "Bearer " + System.getenv("ADJUDON_API_KEY"))
.POST(HttpRequest.BodyPublishers.noBody())
.build();
HttpResponse<String> res = HttpClient.newHttpClient()
.send(req, HttpResponse.BodyHandlers.ofString());
.NET
using var client = new HttpClient();
client.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", Environment.GetEnvironmentVariable("ADJUDON_API_KEY"));
var res = await client.PostAsync(
"https://api.adjudon.com/api/v1/hash-chain/verify",
null);
var json = await res.Content.ReadAsStringAsync();

Response (200 OK, success):

{
"success": true,
"data": {
"verified": true,
"ok": true,
"totalChecked": 17493,
"lastValidSequence": 17493,
"brokenAtSequence": null,
"brokenReason": null,
"durationMs": 842,
"verifiedAt": "2026-05-06T10:14:22.317Z"
}
}

Response (200 OK, broken):

{
"success": true,
"data": {
"verified": false,
"ok": false,
"totalChecked": 17493,
"lastValidSequence": 12047,
"brokenAtSequence": 12048,
"brokenReason": "chain-hash-mismatch",
"durationMs": 841,
"verifiedAt": "2026-05-06T10:14:22.317Z"
}
}

brokenReason is one of prev-hash-mismatch or chain-hash-mismatch. Errors: 401, 403, 500 HASH_CHAIN_VERIFY_FAILED.

Get entry by traceId

GET /api/v1/hash-chain/entry/:traceId
Path parameterRequiredDescription
traceIdyesThe DecisionTrace.traceId to look up
curl
curl https://api.adjudon.com/api/v1/hash-chain/entry/trace_aBcD1234 \
-H "Authorization: Bearer $ADJUDON_API_KEY"

Returns { entry: HashChainEntry, proof?: MerkleProof | null }. Errors: 401, 403, 404 NOT_FOUND, 500 HASH_CHAIN_ENTRY_FAILED.

List entries

Paginated list of chain entries, most recent first.

GET /api/v1/hash-chain/entries
Query parameterRequiredDefaultDescription
limitno50Max entries to return; capped at 500
beforeSeqnoCursor: returns entries with sequence < beforeSeq
searchnoPrefix-match against traceId or chainHash
curl
curl "https://api.adjudon.com/api/v1/hash-chain/entries?limit=50" \
-H "Authorization: Bearer $ADJUDON_API_KEY"
Python
import requests, os
r = requests.get(
"https://api.adjudon.com/api/v1/hash-chain/entries",
params={"limit": 50},
headers={"Authorization": f"Bearer {os.environ['ADJUDON_API_KEY']}"},
)
data = r.json()["data"]
# Cursor walk: pass data['entries'][-1]['sequence'] back as beforeSeq

Response (200 OK):

{
"success": true,
"data": {
"entries": [/* HashChainEntry[] */],
"hasMore": true
}
}

Pagination is cursor-based on sequence. Adjudon does not currently implement Stripe-style starting_after / ending_before; pass the last result's sequence back as beforeSeq to walk further into the past. Errors: 401, 403, 500 HASH_CHAIN_ENTRIES_FAILED.

Get entry by id

GET /api/v1/hash-chain/entries/:id

:id is the entry's MongoDB ObjectId (returned as id in the list response). Errors: 401, 403, 404, 500.

List Merkle anchors

GET /api/v1/hash-chain/anchors

Returns the array of Merkle anchors emitted for the chain. The Merkle anchor roll-up is reserved for Phase 1 (Q3 2026); the endpoint exists today and returns an empty array on chains that have not yet been anchored. Errors: 401, 403, 500 HASH_CHAIN_ANCHORS_FAILED.

Get inclusion proof

GET /api/v1/hash-chain/anchors/:anchorId/proof?sequence=N
ParameterRequiredDescription
anchorIdyesThe Merkle anchor's id
sequenceyes (query)The chain entry's sequence number

Returns the Merkle inclusion proof for the given entry within the given anchor. Phase 1 (Q3 2026); on chains not yet anchored returns 404 NOT_FOUND with code NOT_FOUND. Other errors: 400 VALIDATION_ERROR, 401, 403, 500 HASH_CHAIN_PROOF_FAILED.

Export bundle

The procurement-grade evidence path. Returns a self-contained JSON bundle every chain entry can be replay-verified against, offline, without any further connection to Adjudon.

GET /api/v1/hash-chain/export
GET /api/v1/hash-chain/bundle (alias)
Query parameterRequiredDescription
fromnoISO 8601 lower bound; default: 30 days before to
tonoISO 8601 upper bound; default: now
fromSequencenoSequence-range lower bound; takes precedence over from
toSequencenoSequence-range upper bound; takes precedence over to
formatnoReserved; default json
curl
curl https://api.adjudon.com/api/v1/hash-chain/export \
-H "Authorization: Bearer $ADJUDON_API_KEY" > chain-bundle.json
Python
import requests, os, json
r = requests.get(
"https://api.adjudon.com/api/v1/hash-chain/export",
headers={"Authorization": f"Bearer {os.environ['ADJUDON_API_KEY']}"},
)
r.raise_for_status()
with open("chain-bundle.json", "w") as f:
json.dump(r.json(), f)
Node
import fs from "node:fs/promises";
const res = await fetch(
"https://api.adjudon.com/api/v1/hash-chain/export",
{ headers: { Authorization: `Bearer ${process.env.ADJUDON_API_KEY}` } }
);
await fs.writeFile("chain-bundle.json", await res.text());

The bundle contains every HashChainEntry in range, the chain algorithm reference (sha256 + rfc8785 canonicalization), and the verification recipe. No Adjudon login, no Adjudon endpoint, no Adjudon network is required for the offline replay step. This is the artefact the DORA Article 30 exit-plan obligation expects.

End-to-end audit replay

The procurement-grade engagement is four steps:

  1. Wire — agents emit traces via the SDK; each trace lands as one DecisionTrace and one HashChainEntry in the per-org chain.
  2. Generate — chain rows accumulate as decisions ingest. The lastSequence field on /status shows the count.
  3. Export — when an audit is scheduled, request /export (or /bundle) for the relevant date or sequence range. The response is one self-contained JSON document.
  4. Replay — the auditor recomputes each chainHash from prevHash + payloadDigest + sequence + createdAt against the downloaded bundle, on a laptop, with no Adjudon connectivity. A passing replay returns verified: true; a failure returns brokenAt: <sequence> with brokenReason.

The chain remains valid evidence even if Adjudon is unreachable between step 3 and step 4 — the bundle and the published algorithm are sufficient.

Errors: 401, 403, 500 HASH_CHAIN_EXPORT_FAILED.

Common gotchas

  • Plan-gate. All nine endpoints require the hashChainAudit feature gate (Governance / Enterprise / Custom). Sandbox and Scale receive 403.
  • Read-only. No mutating endpoint exists on this resource; the chain is append-only by Cardinal Rule 5 and append happens implicitly via POST /api/v1/traces.
  • Status vs. Verify. /status returns the cached verification result (cheap, dashboard-friendly); /verify re-replays the entire chain (expensive, audit-grade). Status is suitable for a KPI; verify is suitable for an audit run.
  • Cursor pagination. /entries uses beforeSeq, not Stripe's starting_after. The cursor is the entry's sequence number.
  • Phase 1 fields. merkleRoot, eidasTimestamp, and the /anchors endpoint are Q3 2026 roadmap surfaces; today they return null / empty arrays. The chain works fine without them.

See also