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/echoreceiver is a safe sink (the public demo never calls out to real hosts; add?fail=trueto 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>/deliveriesThe 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.
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-Deliveryid 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.
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 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;- 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
DeliveryWorkerinstances drain the queue with no broker, claiming disjoint batches viaSKIP 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 signing —
X-Hooksmith-Signature: hex(HMAC-SHA256(secret, "{timestamp}.{body}"))plusX-Hooksmith-Timestamp, so consumers verify authenticity and reject replays. - Idempotent ingestion — an
Idempotency-Keyheader 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.
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
InFlightwith a stale lease; the reaper reclaims it toPending. (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.
A relay that POSTs to arbitrary URLs is an SSRF/spam vector, so:
- a built-in receiver at
POST /demo/echo(?fail=trueto simulate a flaky endpoint) means the public demo never needs to call out to real hosts; Delivery:AllowedDeliveryHostsrestricts outbound delivery to an allow-list when set;- ingestion is rate-limited.
- .NET 10, ASP.NET Core Minimal APIs,
BackgroundServiceworkers - EF Core 10 + Npgsql over PostgreSQL; one deliberately-raw
SKIP LOCKEDclaim query - xUnit + Testcontainers integration tests;
WebApplicationFactoryAPI tests IHttpClientFactory, HMAC-SHA256 signing, fixed-window rate limiting
docker compose up --build # api on :8080, postgres on :5432Or run the API against your own Postgres:
export ConnectionStrings__Postgres="Host=localhost;Database=hooksmith;Username=postgres;Password=postgres"
dotnet run --project src/Hooksmith.ApiThen 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| 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) |
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)
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.
MIT