Hash Chain Algorithm
Chain Integrity describes GET /v1/audit/verify, which checks the audit log's hash chain on Stateway's servers. This page documents the underlying algorithm precisely enough that you can recompute and verify the same hash chain independently — for example, over audit log entries you've retrieved or exported yourself — without calling that endpoint.
Fields used in the hash
Each audit log entry carries the fields below. To compute an entry's hash (entryHash), nine values are taken from the entry, in this exact order:
| # | Field | Notes |
|---|---|---|
| 1 | id | The entry's unique identifier, as-is |
| 2 | createdAt | Serialized as ISO 8601 with millisecond precision and a literal Z suffix, e.g. 2026-05-19T10:05:02.000Z |
| 3 | tenantId | The tenant the entry belongs to |
| 4 | actorType | e.g. api_key, system, timer, engine |
| 5 | action | e.g. gateway.resolved |
| 6 | resourceType | The type of resource the entry is about, e.g. instance |
| 7 | resourceId | The resource's identifier. If the entry has no associated resource, this is the empty string '' — not omitted, not null |
| 8 | payload | The entry's payload object, serialized using the stable serialization rule below. If the entry has no payload, this position holds the literal string null |
| 9 | prevEntryHash | The entryHash of the entry immediately before this one in the tenant's chain (see The GENESIS seed for the first entry) |
Building the content string
The nine values are joined into a single string using | (pipe) as a separator, with no escaping applied to the field values. If a field's value happens to contain a | character, it is included verbatim — the algorithm does not attempt to quote or escape it.
Given the nine values above in order, the content string is:
{id}|{createdAt}|{tenantId}|{actorType}|{action}|{resourceType}|{resourceId}|{payload}|{prevEntryHash}
Stable payload serialization
Field 8 (payload) is not serialized with plain JSON.stringify — object key order in JSON is otherwise insignificant, but the hash must be reproducible regardless of the order keys happened to be inserted in. The stable serialization rule is:
- If the payload is absent (not set) or
null, its serialized form is the four-character literal stringnull. - Otherwise, walk the payload's structure recursively. At every level, for any plain object (a JSON object, not an array), reorder its key/value pairs before serializing that object.
- This reordering is not a plain alphabetical sort of the keys. Each pair is compared using the string you get from joining its key and its value with a comma — conceptually
`${key},${String(value)}`— under standard JavaScript string ordering (UTF-16 code unit order), and it's that combined string that decides the order, not the key by itself. This only produces a different result than sorting by key alone when one key is a prefix of another (or two keys otherwise share the same leading characters): the character right after the shared prefix — a comma (,, code point 44) for the shorter key's pair, versus whatever character the longer key has at that position — decides the order, and if that character sorts before a comma, the longer key's pair ends up first even though the longer key is not alphabetically first among the keys on their own. See the second example below. - Arrays are not reordered. Their elements keep their original order. If an array contains objects, each of those objects still has its own keys reordered by the rule above — only the array's element order is left untouched.
- This reordering applies at every depth of the payload, not just the top level.
- The result is otherwise standard JSON (same punctuation, number formatting, and string escaping as
JSON.stringifyproduces).
For example, a payload of:
{ "b": 1, "a": { "d": 2, "c": [ { "z": 1, "y": 2 } ] } }
serializes to:
{"a":{"c":[{"y":2,"z":1}],"d":2},"b":1}
Note that a moved before b, c moved before d, and y/z were reordered inside the object nested in the array — but the array itself ([{ "z": 1, "y": 2 }]) kept its single element in place, and would keep multiple elements in their original order too. None of these keys share a prefix with a sibling, so this example alone doesn't show the divergence described above — see the next example for that.
Where this diverges from sorting by key alone
A payload of:
{ "a": 1, "a b": 2 }
serializes to:
{"a b":2,"a":1}
Sorting the keys alphabetically in isolation would put "a" before "a b" (a shorter string that is a prefix of a longer one sorts first on its own). But the real rule compares "a,1" against "a b,2": both start with a, and the character right after is , (comma, code point 44) for the first pair versus ' ' (space, code point 32) for the second. Since space sorts before comma, "a b,2" sorts first — so the pair keyed "a b" comes before the pair keyed "a", regardless of which one was inserted first. If your own implementation sorts keys in isolation, it will disagree with Stateway's on inputs like this one — use this example to check yours.
The GENESIS seed
The very first audit log entry written for a tenant has no predecessor. For that entry, the hash calculation uses the literal string "GENESIS" as the value of field 9 (in place of a real prevEntryHash).
This matters when reconstructing the chain from retrieved or exported data: the prevEntryHash field on that first entry is empty/null — it is never stored or exposed as the string "GENESIS". So when you see an entry whose prevEntryHash is empty or null, that is your signal to use the literal string "GENESIS" as field 9 when recomputing that entry's hash. Do not use an empty string in its place — "GENESIS" and "" produce different hashes.
Every subsequent entry in the same tenant's chain uses the real, non-empty prevEntryHash value stored on it.
Computing the hash
The final hash is the SHA-256 digest of the content string, computed over its UTF-8 byte encoding, and rendered as lowercase hexadecimal:
entryHash = sha256_hex(utf8_bytes(content_string))
Worked example
Consider a tenant's very first audit log entry:
| Field | Value |
|---|---|
id | log_01j9zqr7xh0000000000000001 |
createdAt | 2026-05-19T10:05:00.000Z |
tenantId | ten_01j9zqr7xh0000000000000abc |
actorType | api_key |
action | instance.created |
resourceType | instance |
resourceId | inst_01j9zqr7xh0000000000000def |
payload | {"correlationId":"order-789"} |
prevEntryHash (stored) | (empty/null — this is the first entry) |
Because this is the first entry, field 9 in the calculation is the literal GENESIS. The content string is:
log_01j9zqr7xh0000000000000001|2026-05-19T10:05:00.000Z|ten_01j9zqr7xh0000000000000abc|api_key|instance.created|instance|inst_01j9zqr7xh0000000000000def|{"correlationId":"order-789"}|GENESIS
Its SHA-256 hex digest — and therefore this entry's entryHash — is:
f630f161b0bf42905f457456e7068ea9e2754dddb3f109a3a00f88b3d048a402
The next entry in the same tenant's chain uses that value as its stored prevEntryHash:
| Field | Value |
|---|---|
id | log_01j9zqr7xh0000000000000002 |
createdAt | 2026-05-19T10:05:02.000Z |
tenantId | ten_01j9zqr7xh0000000000000abc |
actorType | engine |
action | gateway.resolved |
resourceType | instance |
resourceId | inst_01j9zqr7xh0000000000000def |
payload | {"element_id":"gw_credit_check","taken_flows":["flow_approved"],"variables_snapshot":{"credit_score":720,"amount_requested":50000}} |
prevEntryHash (stored) | f630f161b0bf42905f457456e7068ea9e2754dddb3f109a3a00f88b3d048a402 |
Its content string (note the payload's key/value pairs reordered per the stable-serialization rule above at every level — amount_requested before credit_score; these two keys don't share a prefix, so this happens to match plain alphabetical order too):
log_01j9zqr7xh0000000000000002|2026-05-19T10:05:02.000Z|ten_01j9zqr7xh0000000000000abc|engine|gateway.resolved|instance|inst_01j9zqr7xh0000000000000def|{"element_id":"gw_credit_check","taken_flows":["flow_approved"],"variables_snapshot":{"amount_requested":50000,"credit_score":720}}|f630f161b0bf42905f457456e7068ea9e2754dddb3f109a3a00f88b3d048a402
Its entryHash:
18d2fc070b9839acb4cc576209bc33d410369c1e962e66187dd33d16398e9250
Reconstructing and verifying a chain
To verify a sequence of entries for a tenant, ordered by createdAt ascending:
- Seed the expected previous hash from the first entry in your sequence — don't unconditionally use the literal
GENESIS:- If that first entry's stored
prevEntryHashfield is empty/null, it really is the tenant's genesis entry — seed with the literal stringGENESIS(see The GENESIS seed). - Otherwise, seed with that entry's stored
prevEntryHashvalue instead. This is the case whenever you're verifying a partial or windowed sequence — for example, entries retrieved or exported with afrom/tobound — whose first entry isn't the tenant's true first-ever entry. Seeding with the literalGENESISin that case would make the very first comparison fail even though nothing was tampered with.
- If that first entry's stored
- For each entry in order:
- Recompute its
entryHashusing the algorithm above, with the expected previous hash as field 9. - Compare the recomputed value against the entry's stored
entryHash. A mismatch means the entry (or an earlier one) was altered. - Set the expected previous hash for the next iteration to this entry's stored
entryHash.
- Recompute its
- If every entry's recomputed hash matches its stored
entryHash, the chain is intact for the sequence you checked.
Running the steps above yourself lets you validate a chain — including a windowed export or a partial retrieval — from data you already have, without an additional API call.