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
| Field | Type | Nullable | Description |
|---|---|---|---|
id | string | no | MongoDB ObjectId, also exposed as the _id alias |
organizationId | string | no | Owning org's ObjectId; chains never mix across tenants |
sequence | number | no | Monotonic per-org counter, starting at 1 |
traceId | string | no | Foreign key to the DecisionTrace this entry covers |
prevHash | string (64 hex) | no | Previous entry's chainHash; GENESIS_HASH (sixty-four zeros) for the first entry |
payloadDigest | string (64 hex) | no | sha256(canonicalJson(traceView)) — also exposed as payloadHash alias |
chainHash | string (64 hex) | no | sha256(prevHash || payloadDigest || sequence || createdAt) |
createdAt | string (ISO 8601) | no | Append timestamp; immutable |
merkleRoot | string (64 hex) | yes | Reserved for Phase-1 Merkle anchor roll-up (Q3 2026); null today |
merkleLeafIndex | number | yes | Reserved for Phase-1; null today |
merkleAnchorId | string | yes | Reserved for Phase-1; null today |
eidasTimestamp | object | null | yes | Reserved 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 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 -X POST https://api.adjudon.com/api/v1/hash-chain/verify \
-H "Authorization: Bearer $ADJUDON_API_KEY"
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())
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();
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());
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 parameter | Required | Description |
|---|---|---|
traceId | yes | The DecisionTrace.traceId to look up |
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 parameter | Required | Default | Description |
|---|---|---|---|
limit | no | 50 | Max entries to return; capped at 500 |
beforeSeq | no | — | Cursor: returns entries with sequence < beforeSeq |
search | no | — | Prefix-match against traceId or chainHash |
curl "https://api.adjudon.com/api/v1/hash-chain/entries?limit=50" \
-H "Authorization: Bearer $ADJUDON_API_KEY"
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
| Parameter | Required | Description |
|---|---|---|
anchorId | yes | The Merkle anchor's id |
sequence | yes (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 parameter | Required | Description |
|---|---|---|
from | no | ISO 8601 lower bound; default: 30 days before to |
to | no | ISO 8601 upper bound; default: now |
fromSequence | no | Sequence-range lower bound; takes precedence over from |
toSequence | no | Sequence-range upper bound; takes precedence over to |
format | no | Reserved; default json |
curl https://api.adjudon.com/api/v1/hash-chain/export \
-H "Authorization: Bearer $ADJUDON_API_KEY" > chain-bundle.json
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)
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:
- Wire — agents emit traces via the SDK; each trace lands
as one
DecisionTraceand oneHashChainEntryin the per-org chain. - Generate — chain rows accumulate as decisions ingest. The
lastSequencefield on/statusshows the count. - 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. - Replay — the auditor recomputes each
chainHashfromprevHash + payloadDigest + sequence + createdAtagainst the downloaded bundle, on a laptop, with no Adjudon connectivity. A passing replay returnsverified: true; a failure returnsbrokenAt: <sequence>withbrokenReason.
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
hashChainAuditfeature gate (Governance / Enterprise / Custom). Sandbox and Scale receive403. - 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.
/statusreturns the cached verification result (cheap, dashboard-friendly);/verifyre-replays the entire chain (expensive, audit-grade). Status is suitable for a KPI; verify is suitable for an audit run. - Cursor pagination.
/entriesusesbeforeSeq, not Stripe'sstarting_after. The cursor is the entry'ssequencenumber. - Phase 1 fields.
merkleRoot,eidasTimestamp, and the/anchorsendpoint are Q3 2026 roadmap surfaces; today they returnnull/ empty arrays. The chain works fine without them.
See also
- Audit Log & Security — the concept page (chain formula, two parallel chains, four-step verify)
- Error Codes — the full error taxonomy
- Rate Limits — per-key rate-limit behaviour
- DORA Compliance — the Article 30 exit-plan framing this chain export satisfies
- Data Residency & GDPR — the Article 17 erasure mechanism that preserves chain integrity
- Scheduled Chain Export — recipe for periodic export-and-archive