Canonical JSON and the three hashes
What the backend hashes when you send an event, in what order, and how to compute payload_hash yourself.
Two hashes are computed before the 201 and one after. The payload is stored as sent; every hash is computed from it.
- POST /v1/eventsevent_type, payload, optional event_time and Idempotency-Key
- Sort keys, drop whitespacepayload_hash = SHA-256 of these bytes
- event_id, ingested_atrequest_hash = SHA-256 of the envelope
- 201 Createdevent_id and ingested_at; the event is on the queue
- Sign and writeevent_hash, Ed25519 signature, one ledger row
Request
The handler checks the key's write scope and the monthly events quota, then trims event_type. Nothing is hashed yet.
Canonicalize and hash
The payload is canonicalized and serialized. More than 64 KB is 400 payload_too_large. SHA-256 of the bytes is payload_hash.
Envelope and reply
A fresh event_id and ingested_at go into an envelope with the payload as sent. Its SHA-256 is request_hash. The envelope is queued and the 201 returns event_id and ingested_at.
Sign and write
A worker signs compact JSON of v, event_id, tenant_id, event_type, payload_hash, request_hash and ingested_at with your Ed25519 key. It computes event_hash from the payload in sent order and inserts the row once; a redelivered message writes nothing. Until the row exists, get and verify return 404.
The handler parses the payload, rewrites it with canonicalize_json and serializes it again. The same function runs when you send payload to verify.
- Object keys are sorted by Unicode code point at every depth. Uppercase sorts before lowercase, and "10" before "9".
- Arrays keep their order. Each element is canonicalized in turn.
- No whitespace. Members are joined with a comma and a colon only.
- Strings are written as UTF-8. Only quotes, backslashes and control characters are escaped; an escaped input such as \u00e9 comes out as the character.
- Integers are written as sent. Floats are rewritten in shortest form: 1.50 becomes 1.5 and 1e3 becomes 1000.0.
- null stays. Nothing is dropped, so {"a":null} and {} hash differently.
- A key sent twice keeps its last value.
- The 64 KB payload limit is measured on these bytes.
{
"policy_id": "pol_8472",
"decision": "approved",
"approved_by": {
"team": "risk_committee",
"members": ["u_42", "u_7"],
"chair": null
},
"score": 1.50,
"note": "caf\u00e9 r\u00e9sum\u00e9"
}
payload_hash | SHA-256 of the canonical payload bytes, hex. Computed in the handler before the 201. The hash you can reproduce, and the value verify reports as anchored_hash. |
|---|---|
event_hash | SHA-256 of the payload written back as compact JSON in the key order it arrived, without sorting. Computed by the worker. Key order changes it and never changes payload_hash. Get reads the payload back from JSONB, so its key order can differ from what you sent. |
request_hash | SHA-256 of the compact envelope: v (1), event_id, tenant_id, api_key_id, user_id (null for a key), ingested_at, event_type, event_time (null when not sent), payload as sent, idempotency_key (null when not sent) and payload_hash, in that order. Fresh event_id and ingested_at make it unique per ingest. The ledger's unique index on it turns a redelivered message into a no-op. |
| Idempotency-Key check | Not stored on the event. SHA-256 of event_type, then event_time as RFC 3339 when sent, then the canonical payload bytes, concatenated with nothing between. A reused Idempotency-Key is checked against it. |
What verify compares
POST /v1/events/{event_id}/verify takes payload_hash or payload, never both.
- The submitted hash is compared with payload_hash, then request_hash, then event_hash. matched_field names the first match, null when none.
- With payload, the backend canonicalizes and hashes it for you and method is payload. With payload_hash, method is hash.
- anchored_hash is always the stored payload_hash, whether or not it matched.
- Your own compact serialization in the original key order matches event_hash only when your bytes equal the worker's.
This is a hash check. The signature, the public key and the signed bytes are on GET /v1/proof/event/{event_id}.
{
"event_id": "7c1e4b52-9a0f-4d2e-b6f3-2f8a61c0d9e4",
"tenant_id": "0a4f6d18-2b9c-4e7a-8d31-6c5e9f2a7b40",
"event_type": "policy.approval",
"payload": {
"policy_id": "pol_8472",
"approved_by": "risk_committee",
"decision": "approved"
},
"event_time": "2026-09-22T08:14:07Z",
"retention_policy": "standard",
"expires_at": "2027-09-22T08:14:07Z",
"api_key_id": "5e2c8b7a-1d4f-4c93-a6e0-9b3f7d2c1e58",
"ingested_at": "2026-09-22T08:14:07Z",
"payload_hash": "f3c57489bbda1ac2876ce8cef9f7778e07ce2eecd5021907ad2ca2cc3895011d",
"request_hash": "15142ed07f2ed8fc3f7a50ba6d980d2455ad35ba95d8744759b7afe53f092402",
"event_hash": "9831ee77aaae90c0b9cbf5d731c3285967ed73847d9ad7e0d8ec0a39de3c09c7",
"idempotency_key": "policy-approval-pol_8472",
"organization": {
"name": "Northwind Compliance",
"issuer_name": "Northwind Compliance Ltd",
"primary_domain": "northwind.example",
"domain_verified": true,
"domain_verified_at": "2026-08-30T11:20:15Z",
"logo_url": "https://cdn.invoance.com/logos/northwind.png"
}
}
Standard library only. Canonicalize, hash, then compare with payload_hash from GET /v1/events/{event_id}. Node prints true and Python prints True for the example event.
- Node: build the string yourself. JSON.stringify on a sorted object lists integer-like keys first in numeric order, so "9" lands ahead of "10".
- Node writes 1.0 as 1 and the backend writes 1.0. Python writes 0.00001 as 1e-05 and the backend writes 0.00001.
- Strings and integers up to 2^53 match in both. Node rounds larger integers; the backend keeps them.
- For a payload with floats, send payload to the verify endpoint and let the backend canonicalize.
import { createHash } from "node:crypto";
// The backend's rules: keys sorted by code point at every depth, arrays in
// order, no whitespace. Built as a string on purpose: a JS object lists
// integer-like keys first in numeric order, so "9" would land ahead of "10".
function canonical(value) {
if (Array.isArray(value)) return "[" + value.map(canonical).join(",") + "]";
if (value !== null && typeof value === "object") {
const members = Object.keys(value)
.sort()
.map((key) => JSON.stringify(key) + ":" + canonical(value[key]));
return "{" + members.join(",") + "}";
}
return JSON.stringify(value);
}
const payload = { policy_id: "pol_8472", approved_by: "risk_committee", decision: "approved" };
const payloadHash = createHash("sha256").update(canonical(payload), "utf8").digest("hex");
const response = await fetch("https://api.invoance.com/v1/events/7c1e4b52-9a0f-4d2e-b6f3-2f8a61c0d9e4", {
headers: { Authorization: "Bearer " + process.env.INVOANCE_API_KEY },
});
const event = await response.json();
console.log(payloadHash === event.payload_hash); // true