cat work/hookdrop.md
hookdrop: a webhook inspection and replay SaaS, launched solo
- role:
- Solo founder and engineer
- timeline:
- 2025, ~4 months to launch
- stack:
- Go · SQLite · SSE · HTMX · Paystack · LemonSqueezy · Hetzner · Cloudflare
Visit the live product ↗ View the source ↗
Problem and constraints
Debugging webhooks is miserable. You stare at a provider dashboard that shows you a delivery failed, but not why. You add a log line, redeploy, trigger the event again, and hope. When the provider is a payment processor, “hope” is not a strategy.
hookdrop fixes this loop. You create a named endpoint, point any provider at it, and every delivery is captured: headers, body, timing, signature. It verifies signatures against your secret, and you can replay any request against your real service with one click.
I built it alone, under real constraints:
- Solo developer. Every hour spent on infrastructure is an hour not spent on the product. Boring technology was a requirement, not a preference.
- Budget infra. One Hetzner VPS behind Cloudflare. No managed database, no Kubernetes, no queue service.
- Regional payment rails. Stripe was not an option for the account the product bills from. Billing had to work across Paystack for local cards and LemonSqueezy as merchant of record for international customers. Two providers, two webhook formats, two reconciliation paths, on day one.
Key decisions
SQLite over Postgres
Decision: ship on SQLite in WAL mode, backed up continuously to Cloudflare R2.
Why: the entire app runs on one node. SQLite removes a network hop, a connection pool, a managed service bill, and a whole class of operational failure. Reads are local. Backups are a file copy.
Tradeoff: no horizontal scaling story, and write concurrency has a ceiling. For a webhook inspector where each user’s traffic is modest, that ceiling is years away. If it arrives, that is a good problem.
SSE over WebSockets
Decision: live delivery streams use Server-Sent Events, not WebSockets.
Why: the stream is one-way. The server tells the browser “a new delivery arrived.” The browser never needs to push. SSE is plain HTTP: it works through proxies, reconnects natively via Last-Event-ID, and needs no upgrade handshake or ping-pong plumbing.
Tradeoff: no bidirectional channel if I ever need one, and a per-connection goroutine on the server. Both acceptable: replay actions go over normal POST requests, and goroutines are cheap.
Magic links and JWT over passwords
Decision: no passwords. You get a signed link by email, which exchanges for a short-lived JWT.
Why: less attack surface. No password storage, no reset flow, no credential stuffing exposure. For a developer tool used a few times a week, the login friction is fine.
Tradeoff: email deliverability becomes a login dependency, so the email path gets monitoring like any critical system.
Rate limiting and signature verification from day one
Decision: every public endpoint had a token-bucket rate limiter and signature verification before launch, not after.
Why: hookdrop’s core promise is “point your payment provider at me.” That is exactly the kind of endpoint that gets abused. Retrofitting abuse protection after an incident is far more expensive than building it in.
Tradeoff: a week of pre-launch time spent on code no user would ever praise. Worth it the first time a misconfigured client sent 4,000 requests in a minute.
The hard part: Paystack’s shape-shifting JSON
Go’s encoding/json is strict, and that is normally a feature. Then I met a Paystack response where the same field is sometimes a string and sometimes an object.
When you verify a transaction, data.authorization is usually an object with card details. But on certain transaction states, the API returns it as a plain string. Same endpoint, same field name, different JSON type. My decoder did exactly what it should:
// json: cannot unmarshal string into Go struct field
// verifyResponse.data.authorization of type paystack.Authorization
In production this looked like random billing verification failures. Maybe 1 in 40 verifications died, always for the same handful of users, with no obvious pattern in the request. The fix only became clear after I logged the raw response body for failing calls and diffed it against a succeeding one. The failing payloads had "authorization": "AUTH_xxxx" where the passing ones had an object.
The fix is a custom UnmarshalJSON that sniffs the first byte and handles both shapes:
type Authorization struct {
Code string `json:"authorization_code"`
CardType string `json:"card_type"`
Last4 string `json:"last4"`
Bank string `json:"bank"`
// only set when the API sent the string form
RawCode string `json:"-"`
}
func (a *Authorization) UnmarshalJSON(b []byte) error {
b = bytes.TrimSpace(b)
if len(b) == 0 || string(b) == "null" {
return nil
}
// String form: "AUTH_xxxx". Keep the code, leave card fields empty.
if b[0] == '"' {
var code string
if err := json.Unmarshal(b, &code); err != nil {
return fmt.Errorf("authorization string form: %w", err)
}
a.RawCode = code
a.Code = code
return nil
}
// Object form. Alias type avoids infinite recursion into this method.
type alias Authorization
var full alias
if err := json.Unmarshal(b, &full); err != nil {
return fmt.Errorf("authorization object form: %w", err)
}
*a = Authorization(full)
return nil
}
Two lessons came out of this. First, when you integrate a payment API, log raw bodies on decode failure from the start; the error message alone will never tell you the payload changed shape. Second, treat provider API docs as a suggestion and the wire format as the truth.
The limitation I ship with
Honesty section: hookdrop replays requests from its own server. If your service runs on localhost:3000 during development, the replay cannot reach it. Outbound requests from a VPS do not route into your laptop. Today the workaround is deploying to a staging URL or using any tunnel you already have. The real fix is a small CLI that opens a tunnel and receives replays locally, and it is the next thing on the roadmap. I would rather state that plainly than pretend the gap does not exist.
Results and lessons
- 49 registered users at launch, with zero paid marketing. Every one came from posts on X and TikTok.
- Zero downtime through launch week on a single VPS.
- Billing events flowed through both providers in the first week, and reconciliation matched on both.
What I took away:
- Boring infrastructure is a launch strategy. SQLite plus one VPS meant launch week was about users, not incidents.
- Distribution is built before the product. The audience I had been teaching for a year was the entire launch channel. The product took four months; the trust took much longer.
- Disclose your limitations before users find them. The localhost replay gap is in the docs, phrased exactly as it works. Nobody has churned over it; several people replied that the honesty is why they signed up.