What you get
A complete service you can read in an afternoon, not a framework. Your logic goes in one function, handle(); everything around it is already done and tested.
sender --POST--> /webhook/{source}
1. read the body (size limit)
2. verify the HMAC signature -> 401 if wrong, nothing stored
3. dedupe by delivery id -> the same event twice is handled once
4. insert into SQLite -> 202 Accepted, fast
worker ----------> handle(event) retries with backoff, dead after N attempts
Features
- Three signature schemes. GitHub style, Stripe style (with a replay window) and Standard Webhooks, the scheme Polar uses. Constant-time comparison.
- Secret rotation.
WEBHOOK_SECRETtakes a comma-separated list, so old and new secrets are both accepted during a rotation and no events are dropped. - Deduplication. By delivery id (the
webhook-idheader is used automatically for Standard Webhooks) or, if there is none, by a hash of the body. Backed by a uniqueness constraint, so it survives restarts. - Durable queue. SQLite in WAL mode; the request is acknowledged after one commit. Exponential backoff, dead-letter after
MAX_ATTEMPTS, and recovery of events left inprocessingby a crash. - Concurrency.
WORKERS=Nworkers; an event is never given to two workers. - Operations.
server replayfor dead events,server purgeandRETENTION_DAYSfor old rows,/metricsin Prometheus format, a JSON access log with request ids that never contains payloads, headers or secrets. - Safe handlers.
worker.Permanent(err)parks a payload that can never succeed without burning retries. A shutdown mid-handler returns the event to the queue without losing an attempt. - Examples. A Polar
order.paidhandler and a generic JSON router, both with tests. Dockerfile and systemd unit included.
Pure Go (no cgo). Dependencies: the standard library and modernc.org/sqlite.
Measured
Method: the included load tester (make loadtest) against the compiled binary on an Apple Silicon laptop, 32 connections, 512-byte payloads, real HTTP over loopback, default durability (WAL, synchronous=FULL), 5,000 events per run, three runs.
| What | Result |
|---|---|
| Events accepted over HTTP (verify, durable insert, 202), GitHub style | about 9,200 to 9,400 per second (p50 2.4 ms, p99 about 15 ms) |
| Same, Standard Webhooks | about 9,100 to 9,700 per second |
| End to end, from first request to empty queue (no-op handler), 1 worker | about 3,700 to 3,800 per second |
| Queue drain, handler that sleeps 5 ms (like a network call), 1 / 8 / 32 workers | about 160 / 1,460 / 5,500 per second |
How to read it: the database is the bottleneck, not HTTP. All SQLite access goes through one connection, so with a no-op handler extra workers do not help; they pay off when your handler waits on something. Not measured: Linux or cloud VMs and network disks, other payload sizes, millions of rows, TLS or a proxy, memory and CPU, long soak runs. Treat the numbers as "this machine, this setup".
What was tested, and what was not
make test runs go vet and 41 tests with the race detector. They cover tampered, stale, future and malformed signatures, the Standard Webhooks known-answer vector from the reference Go library, secret rotation, 401 storing nothing, duplicate deliveries, oversize and wrong-method requests, retries and backoff, dead-letter, crash recovery, ordering, many workers never processing an event twice, replay, purge and upgrading a 0.1.0 database. The compiled binary, built from the packaged zip, was also exercised with real signed HTTP requests for all three schemes, including a replay of the same delivery.
Not tested:
- A real Polar delivery. The Polar example uses field names from Polar's public documentation and API types, and the handling of older Polar secrets (
raw:prefix) follows what their docs say. Check one real event before relying on either. - The Docker image build and the systemd unit
- Linux or cloud disks, large tables, behaviour behind a proxy or TLS, long soak runs
-raceon Go 1.22 (the suite passes on 1.22 without-race, and with-raceon a newer toolchain)
Who it is for, and who it is not
For developers who need to receive webhooks from Polar, Stripe, GitHub or similar and want the unglamorous parts done right: signatures, retries, dedupe and a queue that survives a crash.
Not for: workloads that need a distributed queue or exactly-once processing across machines. This is a single process with a single SQLite writer, and events are processed in order by default. At-least-once is the honest guarantee, so your handler must be idempotent.
FAQ
Does it verify Polar webhooks?
Yes, Polar uses Standard Webhooks, which is supported. I could not receive a live Polar delivery, so test with one real event.
Can I use only the signature code?
Yes. It is available separately as a small MIT-licensed module, webhooksig; see the free tools page.
Do I need CGO or a C compiler?
No. The SQLite driver is pure Go.
Which Go version?
Go 1.22 or newer.
What is the license?
Use it in your own projects. Do not resell the source as a competing starter.
Changelog
- 0.2.0: Standard Webhooks, secret rotation, multiple workers, replay and purge commands,
/metrics, access log, Polar example, measured numbers. - 0.1.0: first release.
Read more
Get it
Payment, tax and invoices are handled by Polar as merchant of record. You receive a download link after checkout.