Self-hostable · Open source

Never lose a webhook again.

whook captures every inbound webhook the instant it lands, returns a fast 2xx, then delivers with retries and replay.

capture → deliver
200 OKdeliverStripePOST /webhookwhookcapturedsaved to diskdurable storeYour appdelivered

Built for the webhooks you already receive

Pointing a provider straight at your app is fragile.

The provider sends once, at a moment you do not control. Miss it and the event is gone for good.

Without a gateway
webhookStripePOST /webhookYour appdeploying…payment.succeededEVENT LOSTno retry · no record

The event is gone when your app is down

A deploy, a crash, a slow restart. The provider sends once, retries weakly or not at all, and the "payment succeeded" never lands.

Failures leave no trace

When processing throws, the raw request is already gone. You debug blind, with no record of what the provider actually sent.

One URL, many consumers

Billing, email, and analytics all want the same event, but the provider allows a single destination. So you hand-write fan-out glue.

No way to replay

You fixed the bug. Now you need yesterday’s event back, and there is no button for that.

How it works

Capture is decoupled from delivery. That is the whole trick.

An inbound request is saved durably before it is acknowledged. Delivery happens afterward, on whook’s own schedule, so a destination being down never costs you an event.

fast 2xxreplayProviderStripe, GitHub…Ingestcapture + 2xxsignature gateDurable storesaved before ackRouterfan-out + filterDelivery workersretry · backoffYour servicesbilling, email…dead-letter
  1. 01

    Capture

    The raw request is written to disk the instant it arrives, then whook returns a fast 2xx.

  2. 02

    Store

    Every event is kept durably with its headers, body, and source, queryable and replayable.

  3. 03

    Deliver

    Workers forward to your services on their own schedule, retrying until success or dead-letter.

2s6s18sdead-letterbudget exhaustedattempt 1attempt 2attempt 3200 OK · deliveredattempt 4

Retries that back off, then dead-letter.

Transient failures are retried on a deterministic exponential schedule. A 429 honors its Retry-After. When the budget runs out, the delivery is dead-lettered so it stops burning resources and becomes visible.

  • 4xx that will never succeed are marked permanent, not retried.
  • The pending queue lives in the database, so it survives a restart.
  • Replay a dead-lettered event once you have fixed the cause.

One event, many destinations.

A captured event fans out to every matching destination as its own delivery track. Billing, email, and analytics each get their own status, so one failing consumer never blocks the others.

  • Per-destination filters route only the events each service wants.
  • Each track keeps its own attempt history and dead-letter state.
1 eventorder.createdBillingfilter: amount > 0deliveredEmailfilter: type = receiptretryingAnalyticsfilter: *dead-letter

Everything you would otherwise hand-roll.

The pieces you normally bolt together yourself, in one process you run next to your app.

Durable capture

saved before 2xx

Every request is written to disk before the provider is acknowledged, so an event is never lost to a restart.

Backoff retries

exponential + budget

Failed deliveries retry on a deterministic schedule until they succeed or exhaust the retry budget.

Dead-letter and replay

recover anytime

Exhausted deliveries are set aside, queryable, and replayable once you have fixed the cause.

Signature verification

Stripe, GitHub

Pluggable per-provider verifiers. Forged requests are captured for inspection but never delivered.

Fan-out with filters

one event, many

An event resolves to many destinations, each with its own filter, retries, and dead-letter state.

Idempotent capture

Idempotency-Key

A provider dedup key collapses re-sent events into one, even under a concurrent race.

Prometheus metrics

GET /metrics

Ingest rate, write latency, delivery outcomes, and dead-letter volume, ready to scrape.

One static binary

docker compose up

A single Go binary with SQLite built in. Postgres when you outgrow it. Nothing else to run.

Quickstart

Point your provider at whook instead.

Register a source, add a destination, and send. Everything that comes in is captured, listed, and replayable.

Read the docs
bash
# 1. Register a source (your provider integration)$ curl -X POST localhost:8080/sources -d '{"name":"stripe"}' # 2. Point it at one or more destinations$ curl -X POST localhost:8080/destinations \>     -d '{"source":"stripe","url":"https://app.internal/hooks"}' # 3. Send a webhook to whook$ curl -X POST localhost:8080/ingest/stripe \>     -d '{"type":"payment.succeeded","amount":4900}'{"event_id":"evt_9f3a21c8b4e07d56"} # 4. Inspect and replay anything that arrived$ curl localhost:8080/events

Own your inbound webhooks.

Self-host whook next to your app. One binary, your database, your data. No accounts, no per-event pricing.

# pull, configure, run

$ docker compose up -d

whook listening on :8080

  • Single Go binary
  • SQLite or Postgres
  • Prometheus metrics
  • MIT licensed