hookkeep

module
v0.1.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 10, 2026 License: MIT

README

hookkeep

CI Go Reference

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 + customerReferenceCodemerchantId 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

Directories

Path Synopsis
cmd
hookkeep command
Command hookkeep is a self-hosted webhook inbox: receive, verify, persist to Postgres, deliver with retries, inspect and replay.
Command hookkeep is a self-hosted webhook inbox: receive, verify, persist to Postgres, deliver with retries, inspect and replay.
examples
faketarget command
Command faketarget is a demo webhook consumer for the compose example: it logs every delivery it receives and answers 200.
Command faketarget is a demo webhook consumer for the compose example: it logs every delivery it receives and answers 200.
internal
api
Package api is the read/replay JSON API consumed by the embedded UI and by curl.
Package api is the read/replay JSON API consumed by the embedded UI and by curl.
config
Package config loads and validates the hookkeep YAML configuration.
Package config loads and validates the hookkeep YAML configuration.
deliver
Package deliver is the outbound worker: it claims due deliveries from the store and POSTs the original event body to the configured targets with exponential backoff, jitter and a dead-letter terminal state.
Package deliver is the outbound worker: it claims due deliveries from the store and POSTs the original event body to the configured targets with exponential backoff, jitter and a dead-letter terminal state.
ingest
Package ingest is the receive path: POST /in/{source}.
Package ingest is the receive path: POST /in/{source}.
store
Package store is the PostgreSQL persistence layer: the event archive and the delivery queue.
Package store is the PostgreSQL persistence layer: the event archive and the delivery queue.
storetest
Package storetest starts one shared Postgres testcontainer for packages that exercise the real store (ingest, deliver, api, e2e).
Package storetest starts one shared Postgres testcontainer for packages that exercise the real store (ingest, deliver, api, e2e).
verify
Package verify implements webhook signature verification.
Package verify implements webhook signature verification.
Package web embeds the built single-page UI.
Package web embeds the built single-page UI.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL