Audit chain — how tamper-evident history works

Every content change in StaticOwl is hash-chained — each record carries the hash of the one before it, so nothing can be inserted, altered, or removed after the fact without the break becoming visible. The chain is verifiable on demand by anyone with read access to the site: you, your compliance team, or an outside auditor. This page is how.

The contract: if verification returns status: "ok", no record has changed since the chain began without that change being accounted for. Any other status means something was found, and the response says what.


Verify it

There are two ways to check a chain — the Compliance dashboard for a quick read, and the REST API for scripting and scheduled jobs.

From the dashboard

Open System → Compliance. The integrity headline shows whether the chain verifies clean and how many records it holds, and the Frameworks tab turns that into per-control status. See Compliance for the full dashboard.

From the API

Two endpoints on https://app.staticowl.com, authenticated with a site-scoped StaticOwl key (so_…). Both take a graph query parameter — the site's content-store name, shown in your site settings.

GET /api/audit/verify?graph=<name>

Walks the chain from the beginning, recomputes each record's hash, and returns the verdict:

curl "https://app.staticowl.com/api/audit/verify?graph=$SITE_GRAPH" \
  -H "Authorization: Bearer $STATICOWL_API_KEY"
{
  "graph": "staticowl_8f5a2b3c…",
  "status": "ok",
  "records": 142088,
  "last_seq": 142088,
  "last_hash": "9a8b…",
  "verified_at": "2026-06-14T12:00:00.000Z",
  "reason": null
}
Field Meaning
status "ok" (the chain walks cleanly), "broken" (a record's stored hash disagrees with the recomputation), or "empty" (no records yet).
records Total records in the chain.
last_seq Sequence number of the most recent record.
last_hash Hash of the most recent record — pin this in your own audit log to detect future tampering.
verified_at When the walk completed.
reason On "broken", a one-line description (e.g. "record 4283: stored hash mismatch"); null otherwise.

The HTTP status mirrors the kind of answer, not the verdict: 200 whether the chain is clean or broken — the status field carries the verdict, and both are successful verifications. 4xx for an auth or access problem, 5xx if the verification service can't be reached.

POST /api/audit/checklist?graph=<name>

Returns one row per compliance feature — the configured posture, separate from chain integrity:

{
  "rows": [
    { "feature": "audit_chain",      "status": "active",   "version": "v1",
      "last_verified_at": "2026-06-14T12:00:00Z", "message": null },
    { "feature": "worm_mode",        "status": "active",   "version": "v1",
      "last_verified_at": null, "message": null },
    { "feature": "retention_policy", "status": "inactive", "version": null,
      "last_verified_at": null, "message": "no retention policy configured" }
  ]
}

Use it to assert a known posture in CI or a regulator-facing scheduled job.


Access and tenant isolation

Verification checks that your key's owner has access to a site whose content store matches the graph you asked about. A key for one tenant cannot verify another tenant's graph — the response is 404 graph not found, the same whether the graph is in another tenant or doesn't exist, so the boundary doesn't leak which. You never need an engine-level credential; the site-scoped so_ key is the path.


What's being verified

The chain writes one record per content change: the operation, the resulting state, the prior record's hash, and a new hash (hash = sha256(prevHash + serializedOp)). Verification walks from the first record, recomputes each hash, and stops at the first mismatch.

A chain in "ok" state means:

A chain in "broken" state means one of:

In production, "broken" is always an incident — it never resolves on its own. Treat it like a failed build gate.


Operational guidance

Pin last_hash somewhere durable

The chain's protective property is that tampering becomes visible — which requires someone to know what the hash was yesterday, so a discrepancy today stands out. Record yesterday's last_seq + last_hash in your own system (a ticket, a SIEM, a write-once log). Without an external anchor, a rewrite applied consistently from the beginning would leave nothing to compare against.

For a verifiable public anchor you can hand to an auditor or prospect, the Compliance dashboard can publish a trust page that re-checks the chain live — see Compliance.

Run it on a schedule

A typical cadence for a regulated site:

Don't conflate chain status with compliance posture

/api/audit/verify answers "is the chain walkable and untampered?" — an integrity property. /api/audit/checklist answers "what compliance posture is this site configured for?" — a configuration property. A site can have a clean chain with no WORM mode, or WORM enabled with a healthy chain. They're orthogonal.


Troubleshooting

Symptom Likely cause What to do
4xx from verify Key is wrong, expired, or has no access to the site Confirm the key works against GET /api/sites first.
5xx from verify The verification service is unreachable Check GET /api/health, then retry.
status: "broken" Real chain damage Open an incident. The reason names the record. Don't re-stamp the chain — that defeats its purpose.
404 graph not found The key isn't authorized for that site, or the graph name is wrong Confirm the site's content-store name in site settings and that the user has access.

See also

Related docs

Resolved at build time via {% similar %} — cosine similarity over embeddings, not tag overlap. Zero arguments.

Compliance — prove your posture from live data
75% match
Accessibility — the WCAG build check & daily sweep
66% match
Concurrency and last-writer-wins semantics
61% match