← Back to API Documentation
Technical Note
Developer Reference

EVIDE Payload Canonicalization

Deterministic JSON Normalization for Evidentiary Integrity
Specification v1.0 · July 2026, revised October 2026 · EVIDE Schema 2.1
This document specifies the canonicalization algorithm applied to EVIDE JSON intake payloads before SHA-256 hashing. A source system that implements the same algorithm can compute the expected intake hash before submission and compare it with the value returned by the API.
Section 1
1. Why Canonicalization

JSON does not define an order for object keys. The same object can be serialized with its keys in different orders, and each order produces a different byte sequence — and therefore a different SHA-256 hash.

"Payloads that differ only in the order of their object keys must produce the same SHA-256 intake hash."

Without canonicalization, hash verification would be fragile: a developer rebuilding the payload from their system's internal representation in a different key order would obtain a different hash, making integrity verification impossible without the original byte stream.

Canonicalization solves this by defining a single deterministic normalization that all implementations apply before computing the hash. The hash then no longer depends on key order, on whitespace or on the way characters are escaped. The items of extensions and evidence_references are also sorted, so their submitted order usually does not matter, with one exception: items that compare as equal under the sort rules keep their submitted order (Section 2, step 4). The hash still depends on everything else: values and the order of every other array. A client that computes the hash itself also has to reproduce what the server changes when it decodes the payload (Section 2, step 1).

The server computes the hash on the canonical form of the decoded payload: the intake_hash returned in the API response is the SHA-256 of that form. Source systems implementing the same algorithm can compute the expected hash before submission and compare it with the returned value.
Section 2
2. The Algorithm

The server applies the following steps in sequence. A source system that wants to compute the same hash applies the same steps to the request body it sends.

1
Decode the request body
The server decodes the request body with PHP json_decode (associative arrays) and works on the decoded value, not on the bytes that were sent. Whitespace between tokens and the escape form of characters (\u00e8 or è, \/ or /) therefore do not affect the hash. Decoding also has effects that a client has to reproduce: see the note after the steps.
2
Remove content_hash
If the payload contains a top-level content_hash member, remove it before any further processing. This member is a source-side hash declaration — including it in the canonical form would create a circular dependency. Only the top-level member is removed: a member named content_hash inside another object stays in the canonical form.
3
Sort object keys recursively
Sort the keys of every object at every nesting level, including objects inside arrays. Keys are compared byte-wise on their UTF-8 encoding (PHP ksort with SORT_STRING): uppercase letters sort before lowercase letters, _ sorts between them, and non-ASCII characters sort after all ASCII characters.
4
Reorder two arrays
Every array whose member name is extensions is sorted by value with the PHP <=> operator: two strings are compared byte-wise, unless both are numeric strings, which are compared as numbers; an item that is not a string counts as an empty string. For the registered extension names (evidence_references, declarations, declared_uncertainties), the only values the intake accepts in the top-level extensions array, this is plain byte-wise ordering. Every array whose member name is evidence_references is sorted by the composite key artifact_type, pointer, declared_origin, hash.value, compared byte-wise in that order; a missing component counts as an empty string, and no other field is considered. In both arrays, items that compare as equal keep their submitted order: two references with the same artifact_type, pointer, declared_origin and hash.value but a different declared_description, for example, produce a different hash if they are swapped. The rule follows the member name at any nesting level.
5
Keep the order of every other array
All other arrays keep their submitted element order — for example handoff.boundary_readiness.unresolved_signals and declared_uncertainties: the declared order is part of what is hashed. Each element is canonicalized recursively.
6
Minified serialization
Serialize to compact JSON with no whitespace between tokens, encoded as UTF-8. Non-ASCII characters are written as-is, not as \uXXXX escape sequences, and forward slashes are not escaped (/ not \/). Quotation marks, backslashes and control characters are escaped as in any JSON (\", \\, \n, \u0001…), and so are the characters U+2028 and U+2029 (\u2028, \u2029), as PHP json_encode does.
7
SHA-256 hash
Compute SHA-256 of the UTF-8 byte string produced in step 6. Output as lowercase hexadecimal. The result is the EVIDE intake hash.
Effects of server-side decoding. Because the server canonicalizes the decoded payload, a client computing the hash has to reproduce what decoding changes:
— an empty object {} is re-encoded as an empty array [];
— numbers written with a decimal point or an exponent are re-serialized by PHP. With PHP's default serialize_precision setting (-1), which governs how json_encode writes floating-point numbers, 1.0 becomes 1, 1.50 becomes 1.5 and 1e2 becomes 100; these examples assume that setting;
— when a member name appears twice in the same object, only the last value is kept;
— an object whose member names are the integers "0", "1", "2"… can be encoded as an array.
Serialization flags (PHP reference):
json_encode($canonical, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR)

The first two flags determine the output. Without JSON_UNESCAPED_UNICODE, multi-byte characters are escaped as \uXXXX sequences, producing a different byte string. Without JSON_UNESCAPED_SLASHES, forward slashes in URLs are escaped as \/, again producing a different byte string. JSON_THROW_ON_ERROR only turns encoding errors into exceptions.
Section 3
3. Key Ordering Reference

The following tables show the key order for the most relevant objects after canonicalization. These orderings follow from the byte-wise sort in Section 2, step 3, and are provided here as a reference to avoid implementation errors in common nested objects.

Top-level keys (partial)
#Key
1authority
2chain
3created_at_utc
4decision
5evide_schema
6evidence_references
7extensions
8fedis_requested
9handoff
10human_oversight
11intervention
12object_class
13source_reference
14source_system
15source_timestamp_utc
classification_context keys
#Key
1taxonomy_reference
2threshold_authority
3threshold_reference
4threshold_status
handoff.boundary_readiness keys (v2.0 and v2.1)
#Key
1readiness_gate
2status
3unresolved_signals
4visibility_surface
evidence_references[] object keys (v2.1)
#Key
1artifact_type
2declared_description
3declared_origin
4declared_relationship
5declared_retention_status
6hash
7hash_scope
8hashed_by
9pointer
evidence_references[].hash keys (v2.1)
#Key
1algorithm
2value
Array ordering
ArrayOrder in the canonical form
extensionssorted by value with PHP <=>: byte-wise for the registered extension names; numeric strings compared as numbers, non-strings as empty strings; equal items keep their submitted order
evidence_referencessorted by artifact_type, pointer, declared_origin, hash.value, byte-wise; items with equal keys keep their submitted order
any other arraysubmitted order
Verify your implementation: if your computed hash matches the intake_hash returned by the API for the same payload, your implementation reproduces the server's canonical form for that payload. Run this check after integration on representative payloads, including ones that submit extensions and evidence_references in a non-sorted order.
Section 4
4. Worked Example

The following simplified object demonstrates the canonicalization process. It is intentionally limited to the fields needed for this example and is not a complete API-conformant intake payload. The canonical form is what EVIDE hashes.

Simplified input object for canonicalization demonstration — not a complete intake payload
{ "source_system": "Example Governance Systems Ltd", "evide_schema": "2.1", "authority": { "role": "HR Reviewer", "id": "user_87421" }, "decision": { "summary": "Override applied", "status": "finalized", "type": "candidate_evaluation", "closure_timestamp_utc": "2026-04-07T09:15:00Z" }, "source_reference": "CDR-2026-00421", "source_timestamp_utc": "2026-04-07T09:15:00Z", "extensions": ["evidence_references"], "evidence_references": [ { "pointer": "dms://hr/CDR-2026-00421/interview-notes.pdf", "artifact_type": "document" }, { "pointer": "dms://hr/CDR-2026-00421/assessment.pdf", "artifact_type": "document" } ], "content_hash": { "algorithm": "SHA-256", "value": "…" } ← removed before hashing }
After canonicalization (content_hash removed · keys sorted · evidence_references sorted · minified)
{"authority":{"id":"user_87421","role":"HR Reviewer"},"decision":{"closure_timestamp_utc":"2026-04-07T09:15:00Z","status":"finalized","summary":"Override applied","type":"candidate_evaluation"},"evide_schema":"2.1","evidence_references":[{"artifact_type":"document","pointer":"dms://hr/CDR-2026-00421/assessment.pdf"},{"artifact_type":"document","pointer":"dms://hr/CDR-2026-00421/interview-notes.pdf"}],"extensions":["evidence_references"],"source_reference":"CDR-2026-00421","source_system":"Example Governance Systems Ltd","source_timestamp_utc":"2026-04-07T09:15:00Z"}
SHA-256 of the canonical string above
sha256("{"authority":{"id":"user_87421",...}") → efd78be70a7475bdfe534a4233ae4cb2dc6b29b800431bb1a571e52597e29e26
This is the SHA-256 of the canonical string shown above, so you can use the pair as a test vector for your implementation. Note that assessment.pdf now precedes interview-notes.pdf: the two references have the same artifact_type, so pointer decides. A complete payload has a different hash: run the algorithm on your full payload and compare it with the intake_hash returned by the API.
Section 5
5. Language Implementations

Reference implementations of the rules in Section 2. Each one takes the request body as sent (the raw JSON text), decodes it as the server does and returns the intake hash. The evide_canonical_json / evideCanonicalJson functions return the canonical string itself, which is useful for finding the difference when two hashes do not match. The PHP version uses the same calls as the server; for numbers written with a decimal point or an exponent, its output also depends on the serialize_precision setting of the PHP process that runs it.

PHP 8.1+
function evide_canonicalize(mixed $data, int|string|null $context = null): mixed { if (!is_array($data)) { return $data; } if (!array_is_list($data)) { // JSON object: keys sorted byte-wise at every level ksort($data, SORT_STRING); foreach ($data as $k => $v) { $data[$k] = evide_canonicalize($v, $k); } return $data; } // JSON array: only extensions and evidence_references are reordered if ($context === 'extensions') { usort($data, fn($a, $b) => (is_string($a) ? $a : '') <=> (is_string($b) ? $b : '')); } elseif ($context === 'evidence_references') { usort($data, fn($a, $b) => evide_reference_key($a) <=> evide_reference_key($b)); } return array_map(fn($v) => evide_canonicalize($v, $context), $data); } function evide_reference_key(mixed $ref): string { if (!is_array($ref)) { return ''; } $part = fn($v) => is_scalar($v) ? (string)$v : ''; return implode("\x00", [ $part($ref['artifact_type'] ?? null), $part($ref['pointer'] ?? null), $part($ref['declared_origin'] ?? null), $part($ref['hash']['value'] ?? null), ]); } function evide_canonical_json(string $requestBody): string { $payload = json_decode($requestBody, true, 512, JSON_THROW_ON_ERROR); unset($payload['content_hash']); // top level only return json_encode(evide_canonicalize($payload), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR); } function evide_intake_hash(string $requestBody): string { return hash('sha256', evide_canonical_json($requestBody)); }
Python 3.7+
import json, hashlib def _php_string(value): # the string PHP derives from a scalar sort-key component; '' otherwise if isinstance(value, bool): return '1' if value else '' if isinstance(value, (str, int)): return str(value) return '' def _reference_key(ref): if not isinstance(ref, (dict, list)): return '' fields = ref if isinstance(ref, dict) else {} digest = fields.get('hash') return '\x00'.join(_php_string(v) for v in ( fields.get('artifact_type'), fields.get('pointer'), fields.get('declared_origin'), digest.get('value') if isinstance(digest, dict) else None)) def canonicalize(data, context=None): if isinstance(data, dict): if not data: return [] # the server re-encodes {} as [] return {k: canonicalize(data[k], k) for k in sorted(data)} if isinstance(data, list): if context == 'extensions': data = sorted(data, key=lambda v: v if isinstance(v, str) else '') elif context == 'evidence_references': data = sorted(data, key=_reference_key) return [canonicalize(v, context) for v in data] return data def evide_canonical_json(request_body: str) -> str: payload = json.loads(request_body) payload.pop('content_hash', None) # top level only canonical = json.dumps(canonicalize(payload), separators=(',', ':'), ensure_ascii=False) # PHP escapes U+2028 and U+2029; json.dumps does not return canonical.replace('\u2028', '\\u2028').replace('\u2029', '\\u2029') def evide_intake_hash(request_body: str) -> str: return hashlib.sha256(evide_canonical_json(request_body).encode('utf-8')).hexdigest()
JavaScript (Node.js / browser)
const utf8 = new TextEncoder(); // Byte-wise comparison of the UTF-8 encodings, as PHP compares strings function compareBytes(a, b) { const x = utf8.encode(a), y = utf8.encode(b); const n = Math.min(x.length, y.length); for (let i = 0; i < n; i++) { if (x[i] !== y[i]) return x[i] - y[i]; } return x.length - y.length; } function referenceKey(ref) { if (typeof ref !== 'object' || ref === null) return ''; const part = v => typeof v === 'string' ? v : typeof v === 'number' ? String(v) : v === true ? '1' : ''; return [ref.artifact_type, ref.pointer, ref.declared_origin, ref.hash?.value] .map(part).join('\x00'); } // PHP escapes U+2028 and U+2029; JSON.stringify does not function jsonString(s) { return JSON.stringify(s).replace(/\u2028/g, '\\u2028').replace(/\u2029/g, '\\u2029'); } function canonicalJson(value, context = null) { if (Array.isArray(value)) { const items = value.slice(); if (context === 'extensions') { items.sort((a, b) => compareBytes(typeof a === 'string' ? a : '', typeof b === 'string' ? b : '')); } else if (context === 'evidence_references') { items.sort((a, b) => compareBytes(referenceKey(a), referenceKey(b))); } return '[' + items.map(v => canonicalJson(v, context)).join(',') + ']'; } if (value !== null && typeof value === 'object') { const keys = Object.keys(value).sort(compareBytes); if (keys.length === 0) return '[]'; // the server re-encodes {} as [] return '{' + keys.map(k => jsonString(k) + ':' + canonicalJson(value[k], k)).join(',') + '}'; } return typeof value === 'string' ? jsonString(value) : JSON.stringify(value); } function evideCanonicalJson(requestBody) { const payload = JSON.parse(requestBody); delete payload.content_hash; // top level only return canonicalJson(payload); } // Node.js (crypto module) const crypto = require('crypto'); function evideIntakeHash(requestBody) { return crypto.createHash('sha256').update(evideCanonicalJson(requestBody), 'utf8').digest('hex'); } // Browser (Web Crypto API) async function evideIntakeHashBrowser(requestBody) { const buf = await crypto.subtle.digest('SHA-256', utf8.encode(evideCanonicalJson(requestBody))); return Array.from(new Uint8Array(buf)) .map(b => b.toString(16).padStart(2, '0')).join(''); }
Python and JavaScript versions: the JavaScript version compares keys byte-wise on their UTF-8 encoding, because the default JavaScript sort compares UTF-16 code units and orders some non-ASCII keys differently; it also builds the JSON text itself, because JavaScript enumerates integer-like keys first. Both versions escape U+2028 and U+2029 and turn {} into [], as the server does. They are not guaranteed to reproduce the server in these cases:
— numbers written with a decimal point or an exponent, and integers beyond ±(253−1): PHP re-serializes them in its own format (for example 1e25 becomes 1.0e+25);
— objects whose member names are the integers "0", "1", "2"…: the server can encode them as arrays;
— arrays named extensions that contain two or more numeric strings: PHP compares them as numbers, while the Python and JavaScript versions compare them byte-wise.
The fields defined by EVIDE Schema 2.1 do not need any of these; free-form blocks such as execution_identity might. In those cases, compute the hash with the PHP version.
Section 6
6. content_hash and intake_hash

EVIDE uses two distinct hash values for a JSON payload:

  • content_hash (optional, source-declared) — an object {"algorithm": "SHA-256", "value": "…"} in which the source system declares the hash it computed on the canonical payload before submission. It is removed before hashing, stored with the payload as a declaration, and never compared by EVIDE with its own computation.
  • intake_hash (always present, server-computed) — the SHA-256 of the canonical form that the server builds from the decoded payload. It is the reference value computed by EVIDE, returned in the API response and stored with the record.
Client-side check: the client compares the value of its content_hash with the returned intake_hash. If they are equal, the server canonicalized the same content the client hashed. If they differ, the payload changed in transit, the client used a different canonicalization, or the client hashed a non-canonical form — for example without reproducing the decoding effects described in Section 2.

If a FEDIS document is issued for the record, the SHA-256 fingerprint it states for the canonical JSON is the intake_hash, alongside the fingerprint of the frozen Evidentiary Artifact Record (EAR). FEDIS is optional and is generated only after the certifier explicitly initiates generation. The source-declared content_hash is not required for FEDIS.

Scope boundary: the canonicalization algorithm and the intake_hash cover the submitted payload only. The evidentiary_profile — including the Forensic Cross-Check continuity dimension (Dim 9), Decision Wave Compression (Dim 10), and Formal Accountability Collapse (Dim 11) — is computed server-side after intake and is not part of the canonical form. These dimensions do not affect the intake_hash and do not appear in the key ordering reference.
Implementation checklist:
✓ Decode the request body before canonicalizing, or reproduce the decoding effects
✓ Remove the top-level content_hash only
✓ Sort object keys byte-wise, recursively, at every nesting level
✓ Sort extensions by value and evidence_references by composite key, keeping the submitted order of items that compare as equal
✓ Keep the order of every other array
✓ Encode empty objects as []
✓ Minify with no whitespace
✓ Write non-ASCII characters as-is, except U+2028 and U+2029 (\u2028, \u2029)
✓ Do not escape forward slashes
✓ Encode the string as UTF-8 before hashing
✓ Compare your output against the returned intake_hash

References

EVIDE Minimum Intake JSON — Schema Reference: app.certifywebcontent.com/docs/evide-intake-schema/

Live JSON Schema: app.certifywebcontent.com/json

FEDIS Reference: certifywebcontent.com — FEDIS

EVIDE Signals & Roadmap: app.certifywebcontent.com/signals

Canonicalization in one sentence
Sort. Minify. Hash.
The same canonical form always produces the same fingerprint.
Questions or integration support: info@informaticainazienda.it