Hookd sends webhooks for you. You register your customers' endpoints over an HTTP API, choose which event types each endpoint receives, and send events. Hookd stores each event, delivers it in the background to every subscribed endpoint, and signs it under the Standard Webhooks spec, so receivers can check it with an existing library.
Hookd is one Go binary. Its only outside component is the database: a SQLite file locally (nothing to install), PostgreSQL in production. The same binary serves a browser admin UI at /admin, where you can do everything below without a shell: create accounts and API keys, register endpoints, send events, check deliveries, receive test webhooks and change settings.
In this version each delivery is attempted once and failures are recorded, not retried.
- Set up and run from scratch (below)
- Configuration: every
HOOKD_*setting, and which ones the admin UI can change - Deploy to production: PostgreSQL, migrations, several instances behind a load balancer, keeping the admin UI private
- Test locally:
go test ./..., the PostgreSQL suite, and testing by hand - API reference: every route with
curlexamples, and how receivers verify signatures
This walkthrough installs Go, starts Hookd and uses the admin UI in a browser to create an account, register a test endpoint, send it a signed event, check the delivery and the request it received, and change a setting. It takes about five minutes and needs one terminal and a browser. The same steps with curl instead of the UI follow.
Install Go 1.26 or later from https://go.dev/dl/ and check it:
go versionYou also need git. Then clone the repository:
git clone <repository-url> hookd
cd hookdgo run ./cmd/hookd serveThe first run downloads the Go modules, which can take a minute. Hookd then creates the SQLite database ./hookd.db, applies its migrations and listens on port 8080:
level=INFO msg="applied migration" version=0001_init
level=INFO msg="applied migration" version=0002_admin
level=INFO msg=listening addr=[::]:8080
Leave it running. Stop it with Ctrl-C when you are done. To start again from an empty database, delete hookd.db (Hookd recreates it at the next start).
Open http://localhost:8080/ in a browser. It takes you to the admin UI at /admin/, which starts on the Accounts page. Every page shows a No sign-in notice: anyone who can open the UI can use every page, so keep it reachable only by trusted people (see Deploy to production).
Under New account, enter the name acme and click Create account. The next page shows the account's first API key (hk_...) with a Copy button. Copy it now: it is shown only this once, and afterwards the UI shows only its first characters. You need it only to call the API, as in the same with the API.
Click Go to the account. The account page lists its API keys, has Create API key to make more, and Revoke next to each key. It also links to the account's Endpoints and Events.
A test endpoint is an ordinary endpoint whose URL points at a receiver built into Hookd. The receiver checks each request's signature and records it, so you need no separate receiver.
- On the account page, click Endpoints, then New test endpoint.
- In Event types, enter
order.paid. Leave Answer status at200. - Click Create test endpoint.
The endpoint's page opens. Its URL looks like http://127.0.0.1:8080/admin/test-receiver/trcv_.... Show the signing secret shows the whsec_... secret that Hookd signs this endpoint's deliveries with. Received requests is empty for now.
-
Go back to the account (the account name at the top of the page links to it) and click Events, then Send an event.
-
Enter the event type
order.paidand this payload:{"order_id": "1001", "amount": 4200} -
Optionally enter an idempotency key, such as
order-1001-paid. Sending again with the same key returns the first event instead of a new one. -
Click Send event.
The event's page opens with "Event sent". Under Deliveries, the delivery to the test endpoint shows pending for a moment and then succeeded, with HTTP status 200. The page refreshes by itself every 2 seconds while a delivery is unfinished, and Refresh reloads it by hand.
Click the endpoint's URL in the delivery row. Under Received requests, the request is listed with its webhook-id (the event's msg_... ID), the event type order.paid, signature verified, and the status it answered, 200. Headers and Body expand to show what Hookd sent.
To see a failure, set Answer status on the same page to 500, click Save, and send another event: its delivery shows failed with HTTP status 500 and the error non-2xx response: 500 Internal Server Error. The received request still says signature verified: the signature was fine, the receiver just answered 500. An event of a type the endpoint is not subscribed to, such as user.created, creates no delivery.
Click Settings at the top of any page. The Health section shows that the database is reachable, which database file is in use, that migrations are up to date, and how many deliveries are pending or in progress.
The Settings section lists every HOOKD_* setting with its value and where it comes from: default, environment or UI. Change HOOKD_DELIVERY_TIMEOUT to 20s and click Save. Its source becomes UI, and the next delivery uses it, without a restart. Values are checked as at start-up: 90s is refused, because the timeout must be shorter than HOOKD_DELIVERY_LEASE (60s), and nothing is saved. Reset goes back to the environment or default value. The other settings, which Hookd needs at start-up (such as the database and the listen address), are marked "set with environment variables" and cannot be changed here. Configuration has the details.
Everything above can also be done with the command line and the JSON API, with cmd/testreceiver as the receiver instead of the built-in one. This uses three terminals, all in the repository folder, and curl. Start Hookd as in step 2 (terminal 1), then check that it is up in terminal 2:
curl -s http://localhost:8080/healthz{"status":"ok"}go run ./cmd/hookd account create --name acmeaccount_id: acct_01M636GV96GQFMA7V1G5N17DPQ
api_key: hk_1r41QRqtUtyhEHFF6nnQZin__SkqHv-QP20dGALqfvE
Keep the API key safe: it is shown only once.
Put your API key (not the one above) in a variable so the next commands can use it. A key copied from the admin UI works the same way:
export HOOKD_API_KEY=hk_...Register an endpoint at http://127.0.0.1:9000, where the test receiver will listen, subscribed to order.paid:
curl -s -X POST http://localhost:8080/v1/endpoints \
-H "Authorization: Bearer $HOOKD_API_KEY" \
-H "Content-Type: application/json" \
-d '{"url": "http://127.0.0.1:9000", "description": "local test receiver", "event_types": ["order.paid"]}'{"id":"ep_01M636GV9ZGBJMW8ECDR20804R","url":"http://127.0.0.1:9000","description":"local test receiver","event_types":["order.paid"],"enabled":true,"created_at":"2026-10-29T06:45:53.087516Z","updated_at":"2026-10-29T06:45:53.087516Z","secret":"whsec_hPvrVhFTx70CvazjKb2goHGk8fuFZRXq"}Note the secret (whsec_...). Hookd signs every delivery to this endpoint with it. You can fetch it again later with GET /v1/endpoints/{id}/secret.
cmd/testreceiver is a small webhook receiver for development. It checks each request with the official Standard Webhooks Go library and logs whether the signature verified. Start it with your endpoint's secret:
go run ./cmd/testreceiver --secret whsec_...level=INFO msg=listening addr=127.0.0.1:9000 status=200
It listens on 127.0.0.1:9000 and answers every request with 200. Use --addr to change the address and --status 500 to make it answer with a failure instead.
curl -s -X POST http://localhost:8080/v1/events \
-H "Authorization: Bearer $HOOKD_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order-1001-paid" \
-d '{"event_type": "order.paid", "payload": {"type": "order.paid", "order_id": "1001", "amount": 4200}}'Hookd answers 202 Accepted at once, before delivering:
{"id":"msg_01M636H35KBNCA9WXZ8KEEVG0E","event_type":"order.paid","payload":{"type":"order.paid","order_id":"1001","amount":4200},"idempotency_key":"order-1001-paid","created_at":"2026-10-29T06:46:01.13945Z","deliveries":[{"endpoint_id":"ep_01M636GV9ZGBJMW8ECDR20804R","status":"pending","response_status":null}]}Within a moment, the test receiver in terminal 3 logs that the signature verified:
level=INFO msg=verified id=msg_01M636H35KBNCA9WXZ8KEEVG0E type=order.paid body="{\"type\": \"order.paid\", \"order_id\": \"1001\", \"amount\": 4200}" verified=true
Fetch the event by the id from step 4:
curl -s http://localhost:8080/v1/events/msg_... \
-H "Authorization: Bearer $HOOKD_API_KEY"The delivery now shows as succeeded, with the receiver's status:
{"id":"msg_01M636H35KBNCA9WXZ8KEEVG0E", ..., "deliveries":[{"endpoint_id":"ep_01M636GV9ZGBJMW8ECDR20804R","status":"succeeded","response_status":200}]}The event, the endpoint and the account also show up in the admin UI, since the UI and the API work on the same data.
- Stop the test receiver, restart it with
--status 500, send another event, and fetch it: its delivery shows"status":"failed","response_status":500,"error":"non-2xx response: 500 Internal Server Error". (The test receiver still logsverified: the signature was fine, it just answered 500.) - Send an event of another type, such as
user.created: it creates no delivery, since the endpoint is subscribed toorder.paidonly. - Settings are changed with
HOOKD_*environment variables, for exampleHOOKD_DELIVERY_TIMEOUT=20s go run ./cmd/hookd serve; see Configuration.
hookd serve Run the API server, the admin UI and the delivery workers
hookd migrate Apply database migrations
hookd account create --name NAME Create an account and print its API key
hookd apikey create --account ID Create another API key for an account and print it
hookd apikey list --account ID List an account's API keys
hookd apikey revoke --id KEY_ID Revoke an API key so it no longer works
Settings come from HOOKD_* environment variables, and seven of them can also be changed in the admin UI while Hookd runs; see Configuration. cmd/testreceiver is a development tool and is not deployed.
cmd/hookd/ main: serve, migrate, account create, apikey create/list/revoke
cmd/testreceiver/ verifying test receiver (dev/test only)
internal/config/ env parsing and defaults
internal/settings/ settings that change while Hookd runs: environment plus UI overrides
internal/store/ Store interface, SQL implementation, dialects, migrations
internal/api/ handlers, auth middleware, errors
internal/admin/ admin UI: pages, forms, built-in test receiver, embedded templates and static files
internal/validate/ input checks shared by the API and the admin UI
internal/delivery/ worker pool, HTTP sender
internal/signing/ Standard Webhooks signer
internal/ids/ prefixed ULIDs, secret and API key generation
docs/changes/ requirements, design and plan of each change