hookkeep

English | Türkçe
A self-hosted webhook inbox: never lose an event again.
Your endpoint was down for 20 minutes. Stripe retried a few times and gave up.
GitHub kept the delivery in its UI for a while. iyzico sent it three times and
stopped. Where are those events now?
hookkeep sits in front of your internal endpoints and answers that question
with a table you can query:
webhook provider ──▶ hookkeep ──▶ your endpoint
│
Postgres (raw payload, headers, every delivery attempt)
- Persist-first. The raw body and headers are written to Postgres before
anything else happens. If the write fails you get a 500 — never a fake 200.
- Verify signatures with presets (GitHub, Stripe, iyzico, generic HMAC).
Failed verification is still stored as evidence (
rejected), just never
forwarded.
- Deliver with retries: exponential backoff + jitter, per-target
concurrency limits, dead-letter after max attempts. At-least-once, with
X-Hookkeep-Event-Id for consumer-side dedup.
- Replay a single event or a whole time range — from the embedded UI, the
CLI, or the JSON API. "The endpoint is fixed, close the gap" is one command.
- One binary, one Postgres. No Redis, no Kafka, no broker. The queue is a
FOR UPDATE SKIP LOCKED table; the UI is embedded via go:embed.
Quick start (docker compose)
git clone https://github.com/YusufDrymz/hookkeep
cd hookkeep/examples/compose
docker compose up -d
# send a webhook
curl -X POST -H 'Content-Type: application/json' \
-d '{"order":"A-1"}' http://localhost:8080/in/demo
# watch it arrive at the demo target
docker compose logs target
# open the UI (token: demo-token)
open http://localhost:8080
Now break something, exactly like production would:
docker compose stop target # your endpoint goes down
curl -X POST -d '{"order":"A-2"}' http://localhost:8080/in/demo
# ... retries run out, the delivery dead-letters. The event is safe in Postgres.
docker compose start target # endpoint is fixed
docker compose exec hookkeep hookkeep replay \
--config /etc/hookkeep/hookkeep.yaml --source demo --last 10m
# the gap is closed; the UI shows: initial=dead, replay=delivered
Install as a binary
go install github.com/YusufDrymz/hookkeep/cmd/hookkeep@latest
# (the embedded UI ships inside the binary — no Node needed)
export HOOKKEEP_DATABASE_URL='postgres://...'
export HOOKKEEP_UI_TOKEN='a-long-random-string'
hookkeep migrate --config hookkeep.yaml
hookkeep serve --config hookkeep.yaml
Configuration
Secrets never live in the file — the config names the environment variables
that hold them. Unknown fields are an error, so a typo cannot silently
disable verification.
version: 1
database:
dsn_env: HOOKKEEP_DATABASE_URL # env var holding the Postgres DSN
server:
listen: ":8080"
ui_token_env: HOOKKEEP_UI_TOKEN # bearer token for the UI/API (required)
max_body: 1MiB # oversize requests get 413 and are not stored
sources: # POST /in/<name>
- name: stripe
verify: { preset: stripe, secret_env: STRIPE_WEBHOOK_SECRET }
targets: [api]
- name: github
verify: { preset: github, secret_env: GITHUB_WEBHOOK_SECRET }
targets: [api, audit]
- name: iyzico
verify:
preset: iyzico
secret_env: IYZICO_SECRET_KEY
merchant_id_env: IYZICO_MERCHANT_ID # only needed for subscription events
targets: [api]
- name: internal # no verify: accepted as "unverified"
targets: [api]
targets: # where deliveries go
- name: api
url: https://internal.example.com/hooks
timeout: 10s
headers: { X-Env: prod } # static headers added to every delivery
- name: audit
url: http://audit.local/in
retry:
max_attempts: 10
backoff: { base: 5s, max: 15m, jitter: true }
retention: 30d # used by `hookkeep prune`
Signature presets
| Preset |
Header |
Scheme |
github |
X-Hub-Signature-256 |
HMAC-SHA256 over the raw body, hex, sha256= prefix |
stripe |
Stripe-Signature |
HMAC-SHA256 over "<t>.<body>", 5 min timestamp tolerance, multiple v1 accepted (secret rotation) |
iyzico |
X-IYZ-SIGNATURE-V3 |
HMAC-SHA256 over a field concatenation (see below) |
generic-hmac |
configurable |
HMAC-SHA256 over the raw body; header, encoding (hex/base64), prefix options |
iyzico details. iyzico does not sign the raw body; the signed string
depends on the event family, detected from the payload shape:
- payload has
token → HPP (checkout form / pay-with-iyzico):
secretKey + iyziEventType + iyziPaymentId + token + paymentConversationId + status
- payload has
subscriptionReferenceCode → subscription:
merchantId + secretKey + iyziEventType + subscriptionReferenceCode + orderReferenceCode + customerReferenceCode
— merchantId is not in the payload, so configure merchant_id_env
- anything else → direct API payments:
secretKey + iyziEventType + paymentId + paymentConversationId + status
Numeric JSON fields are rendered exactly as they appear in the document, so
large payment ids can't be corrupted by float round-trips. Note that iyzico
only sends the signature header after the webhook signature feature is
activated for the merchant account. Legacy X-IYZ-SIGNATURE (v1) and v2
headers are not supported.
A failed or missing signature never drops the event: it is stored with
verify_status = rejected (and the reason), answered with 401, and excluded
from delivery. You can inspect it in the UI and — deliberately, one by one —
replay it.
CLI
Flags come before positional arguments.
hookkeep serve [--config hookkeep.yaml] ingest + delivery worker + UI, one process
hookkeep migrate [--config ...] [--dry-run] embedded, forward-only schema migrations
hookkeep list [--source s] [--status pending|delivered|dead|rejected] [--since 1h] [--limit n]
hookkeep show [--config ...] <event-id> headers, body, delivery timeline
hookkeep replay [--target t] <event-id> re-enqueue one event
hookkeep replay --source s --last 20m re-enqueue a time range
hookkeep replay --source s --from 2026-07-10T10:00:00Z --to 2026-07-10T10:20:00Z
hookkeep prune [--older-than 30d] [--dry-run] delete old events (deliveries cascade)
hookkeep version
The CLI talks directly to Postgres; it works even when serve is down.
Exit codes: 0 success, 1 not found / nothing matched, 2 tool or config
error.
HTTP API
Everything under /api requires Authorization: Bearer <token>.
| Route |
Description |
POST /in/{source} |
ingest (open by design; verification is per source) |
GET /api/events?source=&status=&since=&until=&limit=&offset= |
newest-first list with delivery counts |
GET /api/events/{id} |
full event: headers, body, delivery timeline |
POST /api/events/{id}/replay |
body {"target":"..."} optional; default = the source's targets |
GET /api/meta |
configured source/target names (feeds the UI dropdowns) |
GET /healthz |
200 when Postgres answers |
The archive is SQL
Events live in a plain events table (deliveries beside it), so your
existing Postgres toolbox — psql, backups, PITR, BI — applies:
-- What did Stripe send yesterday that our API answered 500 to?
SELECT e.id, e.received_at, d.response_status, d.last_error
FROM events e
JOIN deliveries d ON d.event_id = e.id
WHERE e.source = 'stripe'
AND e.received_at >= now() - interval '1 day'
AND d.response_status >= 500;
-- Payload archaeology: find the order in the raw payloads.
SELECT id, received_at
FROM events
WHERE source = 'stripe'
AND convert_from(body, 'UTF8')::jsonb -> 'data' ->> 'id' = 'evt_123';
-- Dead letters by target, this week.
SELECT target, count(*)
FROM deliveries
WHERE status = 'dead' AND created_at >= now() - interval '7 days'
GROUP BY target;
Delivery semantics (the honest part)
- At-least-once. A crash between a successful POST and recording it means
the delivery is retried after the in-flight lease (2 min) expires. Range
replay also re-sends events that were already delivered. Consumers should
dedupe on
X-Hookkeep-Event-Id if duplicates hurt.
- Every delivery carries
X-Hookkeep-Event-Id, X-Hookkeep-Delivery-Id,
X-Hookkeep-Source, X-Hookkeep-Attempt and the original Content-Type
and body, byte for byte.
- Success is any 2xx. Anything else (or a connection error / timeout)
schedules a retry at
base * 2^(attempt-1) capped at max, ±20% jitter,
until max_attempts, then the delivery is dead — visible in the UI/CLI
and replayable.
- Rejected events (bad signature) are stored but never enqueued. Range replay
skips them; single-event replay allows them (an explicit operator decision
after inspection).
- Workers claim deliveries with
FOR UPDATE SKIP LOCKED, so you can run
several serve instances against the same database for availability; they
will not double-claim. (Each instance runs its own ingest + worker.)
What hookkeep is not
Deliberate non-goals, so you can pick the right tool:
- Not send-side infrastructure. hookkeep receives third-party webhooks.
If you need to send webhooks to your customers at scale, look at
Svix or
Outpost.
- Not internet scale. The queue is a Postgres table. That is exactly the
point — one team, one binary, one database — and it comfortably covers the
webhook volume of a typical company. If you need Kafka-grade throughput,
you need a different design.
- No transformations, filtering or rate limiting (yet — see roadmap).
hookkeep forwards the original bytes.
- No multi-tenancy. It is a team tool, not a platform.
Neighbors
| Tool |
Direction |
License |
Infra |
UI |
Range replay |
| hookkeep |
receive |
MIT |
1 binary + Postgres |
embedded |
yes |
| Convoy |
both |
Elastic License v2, license-key gating |
Postgres + Redis (+ PgBouncer/Caddy in the official stack) |
yes |
partly paid |
| Svix (OSS server) |
send |
MIT |
Postgres + Redis |
— |
— |
| Svix Ingest |
receive |
SaaS only |
— |
yes |
yes |
| Outpost |
send |
Apache-2.0 |
Redis + Postgres + message queue |
— |
— |
| whook |
receive |
MIT |
1 binary, SQLite-first (PG optional) |
yes |
single event |
| Hookaido |
receive |
Apache-2.0 |
1 binary, SQLite/PG |
no |
API-level |
| WebhookX |
receive |
Apache-2.0 |
Postgres + Redis |
no |
limited |
If one of those fits your constraints better, use it — this table is here so
you can decide quickly.
Development
go test ./... # unit + integration (needs Docker for testcontainers)
go test -short ./... # pure-unit subset, no Docker
cd web && npm ci && npm test && npm run build # UI (dist/ is committed)
CI runs Go 1.25 × Postgres 16/17 plus the UI tests and build. web/dist is
committed so go install ships the UI; rebuild and commit it whenever
web/src changes (its bytes are platform-dependent, so CI does not diff it).
Roadmap
- v0.2 — range replay + payload diff in the UI, more presets (Shopify,
Slack, Linear, Craftgate), Prometheus
/metrics, automatic pruning,
--only-dead range replay, config hot-reload.
- v0.3 — OIDC for the UI, per-source retention, docs site.
License
MIT