Surface 05 · CanaryAudit
Verify an audit record
Every logged action is sealed with a SHA-256 content hash at write time. Verification recomputes the hash from the stored payload and compares. There are two ways to run that check and they are not interchangeable. POST /canaryaudit/verify/{log_id} is the owner path: it requires authentication and ownership of the record, and anybody else is refused (a 401, 403 or 404) with no verification data. The public path is GET /api/verify/{content_hash}: no auth, 30 requests per minute per IP, and it takes the content hash, not the record id.
curl -sS -X POST https://api.canaryvaults.com/canaryaudit/log \
-H "X-API-Key: $CV_KEY" -H "Content-Type: application/json" \
--data-binary @- <<'JSON'
{
"user_id": "<your-user-uuid>",
"agent_id": "billing-agent",
"action_type": "invoice_export",
"input_trigger": "cron:invoice-run",
"action_detail": "Exported invoice batch 2026-08 to S3",
"outcome": "success"
}
JSON
# → record id + content hash, returned for independent verificationcurl -X POST https://api.canaryvaults.com/canaryaudit/verify/<log-id> \
-H "X-API-Key: $CV_KEY"
# → { "verified": true, "tampered": false, "match": true,
# "stored_hash": "…", "recomputed_hash": "…", "sealed": true, … }# the public check: no auth, no key, takes the 64-char content hash
curl https://api.canaryvaults.com/api/verify/<content-hash>
# → { found, verified, tampered, hash, recordType, sealedAt,
# sealed, anchored, note, basescanUrl } (no user ids, no payload)So the thing you hand a customer or an auditor is the 64-character content_hash returned by POST /canaryaudit/log, never the record id. The same check has a web page at https://www.canaryvaults.com/verify. Both take the 64 hex characters in either case, with or without a sha256: or 0x prefix, and nothing else: paste a UUID record id and the page answers "unrecognized format" without ever reaching the backend.
A record edited after sealing returns "tampered": true with both hashes so you can see the mismatch. The hash covers the exact values of user_id, agent_id, action_type, input_trigger, action_detail, outcome and logged_at, and the content hash of your previous record, so records form a chain: deleting or inserting one breaks it, and the JSON export and the legal PDF report whether the chain is intact. Removing your newest records leaves no break, and anchoring is what shows that. When records are sealed, the records your retention setting removes leave none either: the purge marks the record after them with the server-held seal key, and a record whose predecessor is gone reads as where your trail starts only if it carries that mark and no older record of yours is held. A retention run that cannot make the mark still deletes the records, and the trail then reads as broken there. The rest of metadata is outside the hash, so editing it does not break verification (records logged before chaining began use the older, unchained format and still verify). The extra HMAC seal is opt-in on a server-side key, which is why an untampered record can legitimately report "sealed": false. Exports are JSON on every plan and legal PDF on Pro and Enterprise: tamper-evident, not court-admissible.
You can recompute a record's hash yourself from the JSON export. A record whose metadata.hash_version is 2 hashes canonical JSON of its fields and metadata.prev_hash; an older record, and a Shield or tripwire evidence record, hashes the seven values joined with |, in the order the code below puts them.
# export.json holds the answer of GET /canaryaudit/export/<your-user-uuid>
python3 - <<'PY'
import hashlib, json
r = json.load(open("export.json", encoding="utf-8"))["logs"][0]
names = ("user_id", "agent_id", "action_type", "input_trigger", "action_detail", "outcome")
if str(r["metadata"].get("hash_version")) == "2":
fields = {name: r[name] for name in names}
fields.update(v=2, logged_at=r["logged_at"], prev_hash=r["metadata"].get("prev_hash", ""))
text = json.dumps(fields, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
else:
text = "|".join([r[name].strip() for name in names] + [r["logged_at"]])
print(hashlib.sha256(text.encode()).hexdigest() == r["content_hash"].lower())
PY