Skip to content

heidericklucas/hooksmith

Repository files navigation

Hooksmith

CI .NET License

A reliable webhook delivery service in .NET 10. Register endpoints, POST an event, and Hooksmith guarantees durable, signed, retried delivery with a replayable audit trail — the transactional-outbox + competing-consumers pattern that companies like Stripe and Svix staff teams around, in a small, readable, fully-tested service.

Live API: a hosted instance runs at https://hooksmith.lucashvieira.dev. It's a webhook service, not a web page — drive it with curl. The built-in /demo/echo receiver is a safe sink (the public demo never calls out to real hosts; add ?fail=true to watch retries → dead-letter):

# 1) register an endpoint pointed at the built-in echo receiver  (→ returns id + signing secret)
curl -s https://hooksmith.lucashvieira.dev/v1/endpoints -H 'content-type: application/json' \
  -d '{"url":"https://hooksmith.lucashvieira.dev/demo/echo"}'

# 2) ingest an event  (→ returns an eventId)
curl -s https://hooksmith.lucashvieira.dev/v1/events -H 'content-type: application/json' \
  -d '{"eventType":"order.created","payload":{"id":1}}'

# 3) inspect the signed, retried deliveries fanned out from that event
curl -s https://hooksmith.lucashvieira.dev/v1/events/<eventId>/deliveries

The hard part of webhooks isn't the HTTP POST. It's not losing events when the process dies mid-send, not hammering a downstream that's already down, not double-delivering on retry, and giving consumers a way to verify authenticity and dedupe. Hooksmith does those correctly and needs nothing but Postgres — no Kafka, no Redis, no external broker.

Delivery semantics (read this first)

Hooksmith provides at-least-once delivery, with exactly-once dispatch-claiming and consumer-side dedupe. Precisely:

  • Exactly-once dispatch-claiming — a delivery row is claimed by exactly one worker at a time via SELECT ... FOR UPDATE SKIP LOCKED, so concurrent workers never double-send the same attempt.
  • At-least-once delivery — a consumer can still receive a duplicate (e.g. a worker delivers successfully, then crashes before recording success, so the row is retried). Exactly-once delivery is impossible without consumer cooperation, so Hooksmith doesn't claim it.
  • Consumer-side dedupe — every request carries a stable X-Hooksmith-Delivery id and a signature, so consumers can verify authenticity and discard duplicates.

That distinction is the whole game; anyone who tells you their webhook system is "exactly-once delivery" is hand-waving.

How it works

POST /v1/events ─▶ outbox (event + delivery rows, one transaction)
                      │
       workers ──▶ claim due rows (FOR UPDATE SKIP LOCKED) ──▶ POST (HMAC-signed)
                      │                                            │
                 success ─▶ Succeeded         failure ─▶ backoff ─▶ retry ─▶ DeadLetter ─▶ redrive
                      │
       reaper ──▶ reclaim rows whose worker died mid-flight (stale lease)

The claim query — the one piece that is deliberately raw SQL

The guarantee lives in SKIP LOCKED, so this query is hand-written; everything else uses EF Core. (DeliveryDispatcher.ClaimBatchAsync)

UPDATE deliveries
SET status = 'InFlight', locked_at = @now
WHERE id IN (
    SELECT id FROM deliveries
    WHERE status = 'Pending' AND next_attempt_at <= @now
    ORDER BY next_attempt_at
    FOR UPDATE SKIP LOCKED       -- N workers run this concurrently with zero overlap
    LIMIT @batch
)
RETURNING id;

The pieces

  • Transactional outbox — an event and its per-endpoint delivery rows are written in one transaction, so a crash can never lose or partially enqueue an event.
  • Competing-consumer workers — many DeliveryWorker instances drain the queue with no broker, claiming disjoint batches via SKIP LOCKED.
  • Stale-lock reaper — a worker that dies mid-POST leaves a row InFlight; the reaper reclaims rows whose lease expired so they're redelivered. (This is what actually makes crash recovery work.)
  • HMAC signingX-Hooksmith-Signature: hex(HMAC-SHA256(secret, "{timestamp}.{body}")) plus X-Hooksmith-Timestamp, so consumers verify authenticity and reject replays.
  • Idempotent ingestion — an Idempotency-Key header makes a re-POSTed event return the original receipt instead of enqueuing a duplicate.
  • Jittered exponential backoff → dead-letter → redrive — transient failures retry with backoff; exhausted ones are dead-lettered and can be manually redriven.

How correctness is proven

The guarantees are the kind that look fine until they aren't, so they're tested against a real Postgres with Testcontainers:

  • No double-claiming — two open transactions each claim with FOR UPDATE SKIP LOCKED; the test asserts their claimed sets are disjoint and cover every row.
  • Crash recovery — a row is left InFlight with a stale lease; the reaper reclaims it to Pending. (Simulated deterministically by aging the lock, not by killing a process — a flaky headline test is worse than none.)
  • Idempotency — the same key twice yields one event and one delivery.
  • Backoff & dead-letter — repeated failures schedule a future retry each time, then dead-letter at the attempt cap.
  • Signatures — a delivered request's headers verify against the endpoint secret.

See tests/Hooksmith.Tests. The suite uses Testcontainers by default and accepts HOOKSMITH_TEST_PG to run against an existing Postgres — which is how CI runs it, against a service container.

Safe by default for a public demo

A relay that POSTs to arbitrary URLs is an SSRF/spam vector, so:

  • a built-in receiver at POST /demo/echo (?fail=true to simulate a flaky endpoint) means the public demo never needs to call out to real hosts;
  • Delivery:AllowedDeliveryHosts restricts outbound delivery to an allow-list when set;
  • ingestion is rate-limited.

Tech stack

  • .NET 10, ASP.NET Core Minimal APIs, BackgroundService workers
  • EF Core 10 + Npgsql over PostgreSQL; one deliberately-raw SKIP LOCKED claim query
  • xUnit + Testcontainers integration tests; WebApplicationFactory API tests
  • IHttpClientFactory, HMAC-SHA256 signing, fixed-window rate limiting

Run it locally

docker compose up --build          # api on :8080, postgres on :5432

Or run the API against your own Postgres:

export ConnectionStrings__Postgres="Host=localhost;Database=hooksmith;Username=postgres;Password=postgres"
dotnet run --project src/Hooksmith.Api

Then drive it:

# register an endpoint pointed at the built-in echo receiver
curl -s localhost:8080/v1/endpoints -H 'content-type: application/json' \
  -d '{"url":"http://localhost:8080/demo/echo"}'

# ingest an event (safe to retry with the same Idempotency-Key)
curl -s localhost:8080/v1/events -H 'content-type: application/json' \
  -H 'Idempotency-Key: order-42' \
  -d '{"eventType":"order.created","payload":{"id":42}}'

Run the tests:

HOOKSMITH_TEST_PG="Host=localhost;Database=hooksmith_test;Username=postgres;Password=postgres" dotnet test

API

Method Path Description
POST /v1/endpoints register an endpoint; returns the signing secret once
GET /v1/endpoints list endpoints
POST /v1/events ingest an event (Idempotency-Key header optional)
GET /v1/events/{id}/deliveries deliveries fanned out from an event
GET /v1/deliveries/{id} a delivery and its full attempt history
POST /v1/deliveries/{id}/redrive re-queue a (dead-lettered) delivery
POST /demo/echo built-in receiver (?fail=true → 503)

Data model

endpoints (id, url, secret, event_types[], is_active)
events    (id, event_type, payload jsonb, idempotency_key unique, received_at)
deliveries (id, event_id, endpoint_id, status, attempt_count, next_attempt_at, locked_at)
  └─ delivery_attempts (id, delivery_id, attempt_no, response_status, duration_ms, error, signature)

Roadmap

Per-endpoint circuit breaker; per-PR ephemeral preview environments. Both are intentionally out of the first cut to keep it small and the guarantees easy to verify.

License

MIT

About

Reliable webhook delivery service in .NET 10: transactional outbox, SELECT FOR UPDATE SKIP LOCKED workers, HMAC signing, idempotency, backoff & dead-letter. EF Core + Postgres.

Topics

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors