EngineeringJuly 30, 20268 min read

A Tamper-Evident Webhook Log: Hash Chains and a Public Anchor

A webhook log in a mutable database is a claim, not evidence, because whoever holds the database can rewrite it. How we made AnyHook's event log verifiable by anyone: per-app hash chains, daily seals, and anchors published to a repository we hold no credentials for.

Every webhook service shows you a delivery log. The uncomfortable part is that the log is a row in someone's Postgres, and anyone holding the database, the vendor included, can UPDATE it. Change a payload, backdate a timestamp, or delete a row entirely, and there is no trace. When a dispute hinges on "what exactly did Stripe send you at 14:32," a mutable log isn't evidence. It's a claim, backed by nothing but trust in the vendor.

Audit frameworks say this out loud: logs that could have been tampered with have reduced evidentiary value. So we made AnyHook's event log tamper-evident. This post covers the full design, including the parts that were harder than expected and the ceiling we deliberately accepted.

TL;DR

  • Every event commits a SHA-256 of its plaintext body at write time
  • A daily job folds each app's events into a chain: alter or delete any past event and every subsequent root changes
  • Each day's app roots fold into one public anchor, chained onto yesterday's
  • Anchors are pulled into a public GitHub repository by a workflow AnyHook holds no credentials for. We cannot rewrite what we cannot write
  • The ceiling, stated plainly: tampering inside the current unsealed day is not detectable. The upgrade path exists; nobody has needed it yet

The chain

Three layers, all plain SHA-256.

A leaf, one per event:

leaf = sha256("{id}|{created_at}|{body_hash}")

body_hash is computed from the plaintext body before encryption. That's deliberate: stored bodies are AES-256-GCM encrypted with a random IV, so ciphertext isn't reproducible. Hashing plaintext means anyone holding the payload they were sent can recompute the leaf. Verification never needs our encryption key.

The app root is a fold over the app's day, ordered by (created_at, id), seeded with that app's previous root:

h = prev_root            # "" at genesis
for leaf in leaves:
    h = sha256(h + "|" + leaf)

Editing event #5 changes every hash after it; deleting one shortens the fold; inserting a backdated one shifts it. All three produce a different root.

The day anchor folds every (app_id, root) pair, sorted by app id, seeded with:

h = sha256(prev_anchor + "|" + day)

That day in the seed matters more than it looks. Our first version seeded with prev_anchor alone, which meant a day with zero events produced an anchor identical to the day before, and "two quiet days" was indistinguishable from "one day quietly removed from the timeline." Folding the date in makes every day's anchor distinct, so a gap is itself evidence.

Why per-app chains, not one global chain

One global chain would be simpler. It would also mean that verifying your event requires every other tenant's leaves, which leaks their event IDs and timestamps to you. Per-app chains let a customer verify their own history standalone, and the day anchor only needs the (app_id, root) pairs, which grant nothing.

Why the seal runs daily, off the write path

The obvious design computes the chain on insert. We didn't, because a global hash chain serializes writes (every insert must wait for the previous one's hash), and the ingest path is exactly where a webhook relay cannot afford a bottleneck.

Instead, a cron seals the previous UTC day once. It is never recomputed: once retention purges old events, re-folding would produce a different root, and silently replacing a published anchor is precisely the tampering this system exists to catch.

The cost is a window. Edits made inside the current day, before the seal, are undetectable. For dispute evidence measured in weeks or months, a 24-hour sealing window is irrelevant. If a customer ever needs same-hour guarantees, the upgrade is per-app live chains: apps serialize independently, so they still write in parallel. We wrote the comment naming that ceiling and shipped the simple version.

The anchor is a pull, not a push

The chain alone proves nothing, because whoever controls the database can recompute the whole thing. The anchor has to live somewhere we cannot retroactively edit. Our first draft had the sealing job push each anchor to GitHub with a stored token. Then we inverted it.

The anchors repository runs its own scheduled GitHub Action that fetches our public API and commits what it sees. AnyHook holds no credentials for that repository. The workflow also refuses to rewrite history: it re-fetches the full anchor window every run, and if a previously committed anchor ever differs from what the API now returns, the job fails loudly instead of committing over it.

An anchor we cannot write is worth strictly more than one we can. Inverting the flow also deleted thirty lines of token-handling code, which is the kind of simplification you get to keep.

Verifying, with nothing but a shell

Everything above is reproducible from the public API. The genesis day, checked by hand:

$ printf '%s' "|2026-07-27" | shasum -a 256
f27f9546bc0e021a051d8c469539406b50dded3d21a36a9331095632852413d3

That's the published anchor for 2026-07-27 (zero events and an empty genesis, so the whole fold is one hash of the seed). Day two folds that value in, and so on. GET /api/v1/anchors?day=YYYY-MM-DD returns the (app_id, root) pairs any auditor needs to recompute a day without an account, a key, or our cooperation.

Honest limits

  • The current day is unsealed, as covered above. That's the price of an unserialised write path
  • Retention bounds individual proof. Once an event is purged under its plan's retention, its leaf can't be recomputed. The day's root still commits the count and ordering, but proving a specific payload requires the event to still exist. Longer retention literally buys longer provability
  • Commitments start the day the feature shipped. Events written before it have no body hash and are committed by id and timestamp only. A chain can't retroactively cover history it wasn't measuring

None of this requires a blockchain. Git is already a hash chain, GitHub already timestamps commits, and Certificate Transparency proved the pattern years ago. The only novel decision here is refusing to hold the pen that writes the public record.

All postsJuly 30, 2026 · 8 min

Stop losing webhooks.

Change one URL. Get retries, event log, and one-click replay.