Case Study: Getting Webhooks From an Old App With AfterHook
This case uses the examples/legacy-app folder that ships in every download archive, a stand-in for a real old app, so you can run each step yourself. The commands and output below come from a real run of the downloaded aftermarket 0.5.1. The only extras are the sqlite3 program and Go for the receiver.
The situation
A small order app has been running for years. It writes the current order to status.json, a nightly job drops CSV exports into a folder, and the orders themselves live in a SQLite file. It has no API and no webhooks. The ops team wants to hear about each change, and nobody wants to touch the app.
hook.yaml in the example watches all three. Its one webhook points at a small receiver on port 9900 that checks each signature, rejects old timestamps and drops repeats. Both sides share a secret through the environment:
export HOOK_SECRET=any-long-random-string
go run receiver/main.go
The first look
In a second terminal, with the same secret set, the app’s table gets one order, then AfterHook runs once:
$ sqlite3 app.db "create table orders(id integer primary key, status text, total real); insert into orders values (1, 'open', 39.8);"
$ aftermarket hook run --config hook.yaml --once
{"time":"2026-10-21T13:33:40.360803433Z","event":"baseline","source":"order-status"}
{"time":"2026-10-21T13:33:40.360947013Z","event":"baseline","source":"exports"}
{"time":"2026-10-21T13:33:40.365297096Z","event":"baseline","source":"orders-table"}
Three baselines and nothing sent. AfterHook doesn’t know what changed before it started, so it doesn’t guess.
Three changes
The order gets paid, so the app rewrites status.json with a new status and a new updated_at. A CSV export lands in the folder. A second order arrives in the table. Then one more run:
$ sqlite3 app.db "insert into orders values (2, 'open', 12);"
$ aftermarket hook run --config hook.yaml --once
{"time":"2026-10-21T13:33:40.583887777Z","event":"changed","source":"order-status","event_id":"b61a075d5c707a644348898c","changes":1,"sequence":1}
{"time":"2026-10-21T13:33:40.785180418Z","event":"changed","source":"exports","event_id":"5ceb1c8749caa1d30ad91ba8","changes":1,"sequence":1}
{"time":"2026-10-21T13:33:40.79055887Z","event":"changed","source":"orders-table","event_id":"c12691f42d416cf25f9f0a61","changes":1,"sequence":1}
{"time":"2026-10-21T13:33:40.794261356Z","event":"delivered","webhook":"ops","event_id":"b61a075d5c707a644348898c","attempt":1,"status":200}
{"time":"2026-10-21T13:33:40.794816304Z","event":"delivered","webhook":"ops","event_id":"5ceb1c8749caa1d30ad91ba8","attempt":1,"status":200}
{"time":"2026-10-21T13:33:40.795180058Z","event":"delivered","webhook":"ops","event_id":"c12691f42d416cf25f9f0a61","attempt":1,"status":200}
The receiver printed these three, each with a signature that checked out:
{"id":"b61a075d5c707a644348898c","source":"order-status","kind":"file","sequence":1,"time":"2026-10-21T13:33:40Z","changes":[{"op":"changed","path":"status","old":"open","new":"paid"}]}
{"id":"5ceb1c8749caa1d30ad91ba8","source":"exports","kind":"folder","sequence":1,"time":"2026-10-21T13:33:40Z","changes":[{"op":"added","path":"2026-10-21.csv","new":{"sha256":"fce88805755b086d064447824f2c680bf02e5784804dcf6681bda0253cf325c3","size":23}}]}
{"id":"c12691f42d416cf25f9f0a61","source":"orders-table","kind":"command","sequence":1,"time":"2026-10-21T13:33:40Z","changes":[{"op":"added","path":"1","new":{"id":2,"status":"open","total":12.0}}]}
The status event says one thing: status went from open to paid. The timestamp moved too, but the config lists updated_at under ignore, so it isn’t mentioned. status.json also holds an API token. Had it changed, the event would show REDACTED on both sides, and one of the example’s fixture cases checks exactly that. The new order is one added row, at position 1 of the query result.
A third run right after sends nothing and prints nothing, because nothing changed.
The receiver goes down
Now the receiver is stopped, and order 2 gets paid while it’s away:
$ sqlite3 app.db "update orders set status = 'paid' where id = 2;"
$ aftermarket hook run --config hook.yaml --once
{"time":"2026-10-21T13:33:51.259713211Z","event":"changed","source":"orders-table","event_id":"95b9213e819e8f3f6eb44882","changes":1,"sequence":2}
{"time":"2026-10-21T13:33:51.26234948Z","event":"retry","webhook":"ops","event_id":"95b9213e819e8f3f6eb44882","attempt":1,"reason":"could not connect","retry_in":"1s"}
The run still exits cleanly. The event waits in hook-state.json, a file readable by its owner only. With the receiver back, the next run sends it:
$ aftermarket hook run --config hook.yaml --once
{"time":"2026-10-21T13:33:55.481557657Z","event":"delivered","webhook":"ops","event_id":"95b9213e819e8f3f6eb44882","attempt":2,"status":200}
{"id":"95b9213e819e8f3f6eb44882","source":"orders-table","kind":"command","sequence":2,"time":"2026-10-21T13:33:51Z","changes":[{"op":"changed","path":"1.status","old":"open","new":"paid"}]}
Same id, same bytes as when it was found. If the receiver had taken it once already, it would have printed “repeat of” the id, “ignored”, and moved on.
What it took
One config file and nothing in the app. Run with --once from cron, or leave --once out and AfterHook checks on its own every few seconds. hook test runs the example’s four fixture cases in CI, so a config change that would start reporting a timestamp, or leak a token, fails the build first. The AfterHook reference has the full format.