Skip to main content

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:

#FieldNotes
1idThe entry's unique identifier, as-is
2createdAtSerialized as ISO 8601 with millisecond precision and a literal Z suffix, e.g. 2026-05-19T10:05:02.000Z
3tenantIdThe tenant the entry belongs to
4actorTypee.g. api_key, system, timer, engine
5actione.g. gateway.resolved
6resourceTypeThe type of resource the entry is about, e.g. instance
7resourceIdThe resource's identifier. If the entry has no associated resource, this is the empty string '' — not omitted, not null
8payloadThe entry's payload object, serialized using the stable serialization rule below. If the entry has no payload, this position holds the literal string null
9prevEntryHashThe 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 string null.
  • 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.stringify produces).

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:

FieldValue
idlog_01j9zqr7xh0000000000000001
createdAt2026-05-19T10:05:00.000Z
tenantIdten_01j9zqr7xh0000000000000abc
actorTypeapi_key
actioninstance.created
resourceTypeinstance
resourceIdinst_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:

FieldValue
idlog_01j9zqr7xh0000000000000002
createdAt2026-05-19T10:05:02.000Z
tenantIdten_01j9zqr7xh0000000000000abc
actorTypeengine
actiongateway.resolved
resourceTypeinstance
resourceIdinst_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:

  1. 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 prevEntryHash field is empty/null, it really is the tenant's genesis entry — seed with the literal string GENESIS (see The GENESIS seed).
    • Otherwise, seed with that entry's stored prevEntryHash value instead. This is the case whenever you're verifying a partial or windowed sequence — for example, entries retrieved or exported with a from/to bound — whose first entry isn't the tenant's true first-ever entry. Seeding with the literal GENESIS in that case would make the very first comparison fail even though nothing was tampered with.
  2. For each entry in order:
    • Recompute its entryHash using 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.
  3. 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.