webhook-engine
releasev0.4.0Self-hosted webhook delivery engine with retry, circuit breaker, HMAC signing, rate limiting, and real-time React dashboard. .NET 10 + PostgreSQL. Docker one-liner deploy.
README Snapshot
WebhookEngine
webhook.sametozkan.com.tr · Docker Hub · NuGet
Self-hosted webhook delivery platform with reliable at-least-once delivery, exponential backoff retries, per-endpoint circuit breakers, and a real-time dashboard.
Features
- Reliable delivery -- PostgreSQL-backed queue with
SELECT ... FOR UPDATE SKIP LOCKED, no Redis/RabbitMQ needed - At-least-once semantics -- messages are never lost; stale lock recovery handles worker crashes
- Exponential backoff -- 7 retry attempts (5s, 30s, 2m, 15m, 1h, 6h, 24h)
- Circuit breaker -- per-endpoint, auto-opens after 5 consecutive failures, 5-minute cooldown
- HMAC-SHA256 signing -- Standard Webhooks spec (
webhook-id,webhook-timestamp,webhook-signature) - Idempotency -- optional
idempotencyKeyprevents duplicate deliveries; per-event-type window override - Payload transformation -- per-endpoint JMESPath expression reshapes the body before signing; fail-open with timeout and output-size guards (ADR-003); live editor in the dashboard
- SSRF guard -- endpoint URLs rejected at create / update and at connect time when they resolve to RFC1918 / loopback / cloud-metadata ranges;
SocketsHttpHandler.ConnectCallbackpins the resolved IP for the lifetime of each request (DNS-rebinding defense) - Per-endpoint IP allowlist -- opt-in CIDR positive-list; deliveries are gated against the resolved IP at attempt time
- Per-resource overrides -- per-application rate limit and retention windows; per-event-type idempotency window
- Endpoint test webhook -- send a fully-signed test delivery from the dashboard without enqueueing a real message; signed-request preview included
- Append-only audit log -- admin actions across applications, endpoints, event types, replay, retry, and rotate-key are recorded with before/after snapshots
- Real-time dashboard -- React SPA with live delivery feed and circuit-state changes via SignalR; data layer on TanStack Query
- Single process -- API + background workers + dashboard served from one ASP.NET Core host
- Data retention -- automatic cleanup (delivered: 30 days, dead-letter: 90 days; per-app overrides supported)
- Embeddable customer portal -- ship a
<EndpointManager />React component into your own settings UI; per-application HS256 JWT auth minted by your backend, RFC 6454 dynamic CORS, capability scoping (endpoints:read|write|test,attempts:read); operator-managed signing key + allowed origins from the dashboard
Tech Stack
| Layer | Technology |
|---|---|
| Backend | C# / .NET 10, ASP.NET Core, Entity Framework Core |
| Database | PostgreSQL 17+ |
| Frontend | React 19, TypeScript 6, Vite 8, Tailwind CSS 4, TanStack Query 5, Recharts 3, Lucide React |
| Real-time | SignalR |
| Testing | xUnit, FluentAssertions, NSubstitute, Testcontainers |
| Logging | Serilog (structured JSON) |
| Validation | FluentValidation |
| Observability | OpenTelemetry + Prometheus metrics exporter |
| Deployment | Docker Compose |
Quick Start
[!IMPORTANT] The published image requires a PostgreSQL database — it cannot be launched standalone via Docker Hub's "Run" button. The container's first action on startup is
Database.MigrateAsync(); with no database reachable it exits within seconds. Use one of the two options below.
Docker Compose (recommended)
The bundled compose file starts PostgreSQL alongside the engine, with sensible defaults:
git clone https://github.com/voyvodka/webhook-engine.git
cd webhook-engine
docker compose -f docker/docker-compose.yml up -d
The app starts on http://localhost:5100. Dashboard login: [email protected] / changeme.
Manual docker run against an existing PostgreSQL
If you already operate a PostgreSQL instance, run the image directly and point it at your database:
docker run -d --name webhook-engine \
-p 8080:8080 \
-e ConnectionStrings__Default="Host=your-postgres;Port=5432;Database=webhookengine;Username=postgres;Password=secret" \
-e Dashboard__AdminEmail="[email protected]" \
-e Dashboard__AdminPassword="StrongPassword123!" \
voyvodka/webhook-engine:latest
The container listens on port 8080 internally; map it to whatever host port suits your environment. See docs/SELF-HOSTING.md for the full configuration reference (rate limits, retention, retry policy, signing secret rotation, etc.).
Local Development
Prerequisites: .NET 10 SDK, PostgreSQL 17+, Node.js 20+, Bun 1.2+
- Start PostgreSQL (or use the dev compose file):
docker compose -f docker/docker-compose.dev.yml up -d
- Configure connection string in
src/WebhookEngine.API/appsettings.json:
{
"ConnectionStrings": {
"Default": "Host=localhost;Port=5432;Database=webhookengine;Username=webhookengine;Password=webhookengine"
}
}
- Run the backend (migrations auto-apply on startup):
dotnet run --project src/WebhookEngine.API
The API starts on http://localhost:5128.
- Run the dashboard (optional, for frontend development):
cd src/dashboard
bun install
bun run dev
Dashboard dev server runs on http://localhost:5173 with API proxy to localhost:5128.
Documentation
- Getting Started — from zero to first webhook
- Self-Hosting Guide — production deployment and operations
- Customer Portal Guide — embeddable portal: operator setup, JWT minting, security model
- Release Guide — Docker Hub and NuGet publishing flow
- Roadmap — current phase status and upcoming priorities
- PRD — product scope, goals, and requirement definitions
- API Reference — full endpoint documentation
- Architecture — system design and component overview
- Database — schema and PostgreSQL notes
- Contributing — local setup and pull request workflow
- Changelog — notable project changes
- Samples guide, Sample Sender, and Sample Receiver
- Signature verification helpers for C#, TypeScript, and Python
Architecture
+---------------------------+
| ASP.NET Core Host |
| |
HTTP requests -> | Controllers (REST API) |
| Middleware (auth, logging) |
| Static files (React SPA) |
| SignalR Hub |
| |
| Background Workers: |
| - DeliveryWorker |
| - RetryScheduler |
| - CircuitBreakerWorker |
| - StaleLockRecovery |
| - RetentionCleanup |
+------------+--------------+
|
v
+---------------------------+
| PostgreSQL 17+ |
| - Data storage |
| - Job queue (SKIP LOCKED) |
+---------------------------+
Solution Structure
src/
WebhookEngine.Core/ # Domain: entities, enums, interfaces, options
WebhookEngine.Infrastructure/ # EF Core, PostgreSQL queue, repositories, services
WebhookEngine.Worker/ # Background services (delivery, retry, circuit breaker)
WebhookEngine.API/ # ASP.NET Core host, controllers, middleware
WebhookEngine.Sdk/ # .NET client SDK
dashboard/ # React SPA (Vite + TypeScript)
tests/
WebhookEngine.Core.Tests/
WebhookEngine.Infrastructure.Tests/
WebhookEngine.API.Tests/
WebhookEngine.Worker.Tests/
API Overview
Base URL: /api/v1/
Authentication
- API key (for programmatic access):
Authorization: Bearer whe_{appId}_{random} - Cookie auth (for dashboard):
POST /api/v1/auth/login
Endpoints
| Method | Path | Auth | Description |
|---|---|---|---|
GET |
/health or /api/v1/health |
None | Health check |
GET |
/health/live |
None | Liveness probe (process up) |
GET |
/health/ready |
None | Readiness probe (DI + DB ready, migrations applied) |
POST |
/api/v1/auth/login |
None | Dashboard login |
POST |
/api/v1/auth/logout |
Cookie | Dashboard logout |
GET |
/api/v1/auth/me |
Cookie | Current user |
GET |
/api/v1/applications |
Cookie | List applications |
POST |
/api/v1/applications |
Cookie | Create application |
GET |
/api/v1/applications/{id} |
Cookie | Get application |
PUT |
/api/v1/applications/{id} |
Cookie | Update application (incl. retention / rate-limit overrides) |
DELETE |
/api/v1/applications/{id} |
Cookie | Delete application (cascades to messages) |
POST |
/api/v1/applications/{id}/rotate-key |
Cookie | Rotate API key |
POST |
/api/v1/applications/{id}/rotate-secret |
Cookie | Rotate signing secret |
GET |
/api/v1/event-types |
API key | List event types |
POST |
/api/v1/event-types |
API key | Create event type |
GET |
/api/v1/endpoints |
API key | List endpoints |
POST |
/api/v1/endpoints |
API key | Create endpoint (URL DNS-validated) |
PUT |
/api/v1/endpoints/{id} |
API key | Update endpoint |
DELETE |
/api/v1/endpoints/{id} |
API key | Delete endpoint (cascades to messages) |
POST |
/api/v1/endpoints/{id}/disable |
API key | Disable endpoint |
POST |
/api/v1/endpoints/{id}/enable |
API key | Enable endpoint |
POST |
/api/v1/messages |
API key | Send message |
POST |
/api/v1/messages/batch |
API key | Batch send messages |
POST |
/api/v1/messages/replay |
API key | Replay historical messages |
GET |
/api/v1/messages |
API key | List messages |
GET |
/api/v1/messages/{id} |
API key | Get message |
GET |
/api/v1/messages/{id}/attempts |
API key | List attempts |
POST |
/api/v1/messages/{id}/retry |
API key | Retry message |
GET |
/api/v1/dashboard/overview |
Cookie | Dashboard stats |
GET |
/api/v1/dashboard/timeline |
Cookie | Delivery chart data |
GET |
/api/v1/dashboard/event-types |
Cookie | List event types (cross-app) |
POST |
/api/v1/dashboard/event-types |
Cookie | Create event type |
PUT |
/api/v1/dashboard/event-types/{id} |
Cookie | Update event type |
DELETE |
/api/v1/dashboard/event-types/{id} |
Cookie | Archive event type |
POST |
/api/v1/dashboard/endpoints/{id}/test |
Cookie | Send a customizable, fully-signed test webhook |
POST |
/api/v1/dashboard/transform/validate |
Cookie | Validate a JMESPath expression against a sample payload |
GET |
/api/v1/dashboard/audit |
Cookie | List audit log entries (filterable) |
Send a Message
curl -X POST http://localhost:5100/api/v1/messages \
-H "Authorization: Bearer whe_abc123_your-api-key" \
-H "Content-Type: application/json" \
-d '{
"eventType": "order.created",
"payload": {"orderId": 42, "amount": 99.99},
"idempotencyKey": "order-42"
}'
Response:
{
"data": {
"messageIds": ["msg_abc123..."],
"endpointCount": 2,
"eventType": "order.created"
},
"meta": { "requestId": "req_..." }
}
.NET SDK
using WebhookEngine.Sdk;
using var client = new WebhookEngineClient("whe_abc_your-api-key", "http://localhost:5100");
await client.Messages.SendAsync(new Send
Changelog
Changelog
All notable changes to this project are documented in this file.
The format is based on Keep a Changelog, and this project follows Semantic Versioning.
[Unreleased]
Added
Changed
Fixed
Removed
Security
[0.4.0] - 2026-07-29
Reliability and security release. A full audit closed the delivery-correctness and API defects behind double delivery under stale-lock recovery, silent message loss on a failed fan-out enqueue, a racy manual retry, a circuit-open endpoint burning a message's retry budget, unbounded list pagination, and a dashboard whose live counters silently froze after 20 events — plus three security fixes: an unauthenticated SignalR hub leaking cross-tenant delivery data, connect-time IP-allowlist enforcement against DNS rebinding, and login rate limiting. Adds the operator backlog metrics an alert can actually fire on, and repairs three query paths that degraded as messages and message_attempts grew. No breaking API changes; the v1 prefix, the Standard Webhooks signature surface, and the WebhookEngine.Sdk public API are unchanged.
Added
- Operator backlog metrics an alert can actually fire on. Three new Prometheus gauges expose the real delivery backlog:
webhookengine_queue_depth(messagesPendingand due for delivery),webhookengine_queue_oldest_pending_age(seconds since the oldest due message was scheduled — head-of-line latency, the real SLO signal), andwebhookengine_circuit_open(endpoints whose circuit breaker is currently Open). A background collector refreshes them from the database every 15s so the scrape path stays cheap.
Changed
- Backend dependency refresh. The .NET 10 runtime stack moves
10.0.9 → 10.0.10(EF Core, EF Core Relational/Design,Microsoft.Extensions.Http/Hosting/DependencyInjection,Microsoft.AspNetCore.OpenApi,Microsoft.AspNetCore.Mvc.Testing), OpenTelemetry1.16.0 → 1.17.0(OTLP exporter, hosting extensions, ASP.NET Core and runtime instrumentation),Scalar.AspNetCore2.16.11 → 2.16.16, andSystem.IdentityModel.Tokens.Jwt8.19.1 → 8.21.0. Test-only:Microsoft.NET.Test.Sdk18.7.0 → 18.8.1andNSubstitute5.3.0 → 6.0.0(major; the substitute APIs the suite uses are unchanged and the full suite passes). No user-facing behaviour change. - Frontend dependency refresh. Shipping in the dashboard bundle:
react-router8.2.0 → 8.3.0,lucide-react1.24.0 → 1.27.0,recharts3.9.2 → 3.10.1, andtailwindcss/@tailwindcss/vite4.3.2 → 4.3.3. Build and lint tooling:vite8.1.4 → 8.1.5,@vitejs/plugin-react6.0.3 → 6.0.4,eslint10.7.0 → 10.8.0, andtypescript-eslint8.63.0 → 8.65.0across the dashboard and@webhookengine/endpoint-manager, plus@testing-library/jest-dom6.10.0 → 7.0.0(dev-only) in the package. TypeScript stays on 6.0.3 — the 7.0 line crashes@typescript-eslint/typescript-estree, so the bump waits on upstream support. No user-facing behaviour change. - CI and container base images refreshed.
actions/setup-node6 → 7andactions/setup-dotnet5 → 6in the workflows; themcr.microsoft.com/dotnet/sdk:10.0digest is repinnedea8bde3 → ed034a8(build stage only) and the runtimemcr.microsoft.com/dotnet/aspnet:10.0-alpinedigest57bd717 → 27b6b84.
Fixed
- Query performance on the growth paths: two indexes and a streaming p95. As
messagesandmessage_attemptsgrow into the millions, three hot paths degraded. (1) The retry sweep (RetryScheduler, every 10s) filtersstatus='Failed' AND scheduled_at <= nowacross all applications, which the app-scopedidx_messages_app_statuscannot serve (its leading column isapp_id, and PostgreSQL 17 has no B-tree skip scan) — it was a sequential scan of the whole table every tick. A new partial indexidx_messages_retryonscheduled_at WHERE status='Failed'serves it directly. (2) The dashboard timeline query scansmessage_attemptsbycreated_atalone, which no existing index covered; a newidx_message_attempts_created_atserves it. (3) The per-endpoint stats route computed p95 latency by loading every attempt's latency for the period into memory and indexing the sorted list — an out-of-memory vector on a busy endpoint over a 30-day window, reachable with an ordinary API key. It now computes count, success, average, and p95 in a single aggregate query usingpercentile_cont(0.95), so nothing is materialized. The stats response shape is unchanged; p95 is now a continuous-interpolated percentile (the standard definition) rather than the previous nearest-rank sample, so the value can differ slightly for the same data. webhookengine_queue_depthno longer drifts unbounded-negative, andRetryPolicy:MaxRetriesis no longer dead config. The queue-depth metric was an up/down counter incremented on enqueue and decremented on dequeue, but retries, reschedules, and stale-lock recovery re-enter a message intoPendingwithout an increment — so every retry left the counter one lower, marching it monotonically negative and making the documentedqueue_depth > Nbacklog alert unfireable. It is replaced by an observable gauge sourced from an actualCOUNTof due-pending rows (see Added). Separately,WebhookEngine:RetryPolicy:MaxRetrieswas bound, shipped inappsettings, and documented, but never read — a message's retry cap came from the hardcoded entity default, so tuning the knob did nothing;PostgresMessageQueue.EnqueueAsyncnow stamps the configured value onto every enqueued message (and a fail-fastValidateOnStartvalidator rejects a non-positiveMaxRetriesor empty backoff schedule at boot). Note: replaying a message now applies the current configuredMaxRetriesrather than the original message's cap. Finally, the DockerHEALTHCHECKnow probes/health/ready(database-aware) instead of/health(liveness), so a container whose database has died is marked unhealthy and restarted instead of answering 200 indefinitely.- List endpoints are bounded, and date filters accept naked dates. Every paginated list route (messages, endpoints, event types, applications, audit logs, attempts, and the dashboard/portal equivalents — 11 in all) now clamps its inputs through one shared
PaginationBounds.Clamp(page >= 1,pageSizein1..100). Previously only the audit-log route clamped:?pageSize=2000000000emittedLIMIT 2000000000and could materialize an entire tenant'smessagestable (jsonb payloads included) into the single-process host's memory,?page=0producedOFFSET -20and a 500, and?pageSize=0divided by zero in the pagination metadata — all now bounded, and the metadata computation is guarded against a zeropageSizeregardless. Separately, date-range filters (after/before, replayfrom/to, auditfrom/to) bound a timezone-less value like?after=2026-07-01asUnspecified, which Npgsql 10 refuses to bind to atimestamptzparameter — a 500 on an ordinary request; inbound filter timestamps are now normalized to UTC. Finally, the endpointsecretOverridesigning-secret rule (must bewhsec_-prefixed, 32-64 chars) was enforced only on the portal surface — the adminPUT /endpoints/{id}and both dashboard endpoint forms accepted a 1-char secret, silently weakening HMAC authenticity; the rule now applies on all of them, and its length ceiling is aligned to thevarchar(64)column so an over-long value is a clean 422 instead of a 500 at INSERT. - Dashboard live auto-refresh no longer silently freezes after 20 events. The delivery feed hook (
useDeliveryFeed) keeps a rolling list capped atmaxEvents, soevents.lengthclimbed to the cap and then never changed again. Both primary monitoring screens (Messages, Dashboard) drove their TanStack Query invalidation offevents.length, so from the 21st SignalR event onwardinvalidateQuerieswas never called: the live-event strip kept ticking (it reads the array contents) while the message table and 24-hour stat counters silently froze — the page looked live but lied about delivery state. The hook now exposes a monotoniceventCountthat increments on every event and is never sliced, and both screens invalidate off that instead, so the table and counters stay live indefinitely. - Manual retry is now race-safe, and a circuit-open endpoint no longer burns a message's retry budget. Two more delivery-correctness holes: (1)
POST /messages/{id}/retry(and its dashboard twin) read the message status, checked it wasFailed/DeadLetter, then called an unguardedRetryAsyncthat reset the row toPending, AttemptCount=0by id alone. Between the check and the write, the retry scheduler could requeue the message and a worker could pick it up (Sending) — the reset then resurrected an actively-delivering message, causing a double delivery.RetryAsyncis now a compare-and-swap (WHERE status IN (Failed, DeadLetter)) that returns whether it acted; the endpoints return409 Conflictwhen the row changed underneath them instead of silently resetting it. (2) When an endpoint's circuit was open, each deferral incremented the attempt counter and rescheduled toCooldownUntil— which, in the window between cooldown expiry and the breaker flipping to half-open, was already in the past, so the message spun (re-due immediately, no real delivery) and could exhaust its retries and dead-letter at the very moment the endpoint recovered. A circuit-open deferral is now a pure reschedule: no attempt increment, clamped to a future time, and never a premature dead-letter — the real retry budget is spent only on actual half-open probe deliveries. Covered by real-PostgreSQL CAS and circuit-deferral regressions (mutation-verified). - Delivery lifecycle hardened against double-delivery, lost attempt rows, and state regression. Three intertwined worker defects that broke the "at-least-once, no-double-delivery, crash-safe" contract are now closed. (1) A batch of messages received a single
locked_atat dequeue, so the tail of a slow sequential batch could cross the stale-lock threshold while a live worker was still delivering it —StaleLockRecoveryWorkerwould reclaim it and a second worker would redeliver.DeliveryWorkernow re-asserts ownership per message via a CASRefreshLockAsync(only the current owner of a still-Sendingrow keeps the lease); a lost lease skips delivery instead of duplicating it. (2) The graceful-shutdown token was threaded into both the HTTP POST and the follow-on DB writes, so on SIGTERM an in-flight delivery was aborted and mislabeled a timeout while its attempt row and terminal status were never persisted — leaving the row stuckSendingfor a later redelivery. Delivery now runs on a per-request timeout independent of shutdown, and every attempt/terminal write commits withCancellationToken.None, so an in-flight message finalizes cleanly inside the shutdown grace window. (3) Error recovery reset a message toPendingfrom stale in-memory status, which after a stale-lock steal could regress aDelivered/DeadLetterrow back toPending; the reset is now a CASResetToPendingIfOwnedAsyncthat only fires when the worker still owns aSendingrow. A fail-fastDeliveryOptionsvalidator (ValidateOnStart) now rejects a configuration whereTimeoutSeconds * 1.5 >= StaleLockMinutes * 60(or any non-positive core knob), so an operator cannot silently re-open the stale-steal window by mistuning timeouts. Covered by real-PostgreSQL (Testcontainers) lease-ownership and stale-steal regressions, a fullMessageStateMachinetransition-matrix suite, and validator unit tests. - A failed enqueue no longer silently drops a sibling message (poisoned-
DbContextfix). When a webhook was fanned out to several subscribed endpoints, `PostgresMessag
Releases
- WebhookEngine v0.4.0Open on GitHub
WebhookEngine v0.4.0
Reliability and security release. A full audit closed the delivery-correctness and API defects behind double delivery, silent message loss, and racy manual retries, alongside three security fixes and the operator backlog metrics an alert can actually fire on. No breaking API changes — the
v1prefix, the Standard Webhooks signature surface, and theWebhookEngine.Sdkpublic API are unchanged.Security
- Authenticated SignalR delivery hub.
/hubs/deliverieswas mapped with no authorization while the notifier fans every delivery event toClients.All— any anonymous client that could reach the host received the system-wide delivery firehose for all applications, defeating the AppId isolation enforced everywhere else. The hub now requires the dashboard cookie scheme, with/negotiategated too. - IP allowlist enforced at connect time (DNS-rebinding hardening). The allowlist was checked in the worker against its own DNS resolution, but the delivery connection re-resolved the host and pinned to that result. Hostile DNS could return an allowlisted IP to the check and a different one to the connection. The connect callback now enforces the allowlist on the same resolution it pins to.
- Login rate limiting.
POST /api/v1/auth/loginhad no throttle, so seeded admin credentials could be brute-forced online. It now carries a fixed-window limiter (5 attempts/minute per client IP, configurable underWebhookEngine:LoginRateLimit). - Portal endpoint-test preview no longer leaks custom-header values. The portal test response redacts every operator-configured custom-header value while preserving the Standard Webhooks signed headers.
Fixes
- Delivery lifecycle hardened. Per-message CAS lock refresh stops
StaleLockRecoveryWorkerfrom reclaiming a message a live worker is still delivering; delivery now runs on a per-request timeout independent of shutdown so an in-flight message finalizes cleanly on SIGTERM; and error recovery is a CAS reset that can no longer regress aDelivered/DeadLetterrow back toPending. - A failed enqueue no longer silently drops a sibling message. A poisoned
DbContexton fan-out meant a later endpoint never received the webhook while the API still returned success. - Manual retry is race-safe, and a circuit-open endpoint no longer burns a message's retry budget into a premature dead-letter.
- List pagination is bounded across all 11 paginated routes (
?pageSize=2000000000could materialize an entire tenant'smessagestable), date filters accept naked dates, and thesecretOverridesigning-secret rule is enforced on every surface. - Dashboard live auto-refresh no longer freezes after the 20-event feed cap — the message table and 24-hour counters stayed frozen while the page still looked live.
- Query performance on the growth paths. New partial index for the retry sweep, an index for the timeline scan, and endpoint-stats p95 computed via
percentile_continstead of loading every attempt's latency into memory.
Features
- Operator backlog metrics. Three new Prometheus gauges —
webhookengine_queue_depth,webhookengine_queue_oldest_pending_age(head-of-line latency, the real SLO signal), andwebhookengine_circuit_open— refreshed by a background collector every 15s so the scrape path stays cheap. These replace a queue-depth up/down counter that drifted monotonically negative on every retry, making the documented backlog alert unfireable.
Upgrade notes
webhookengine_queue_depthchanges semantics from a drifting counter to a real gauge; existing alerts on it become meaningful rather than dead.- Endpoint-stats p95 is now a continuous-interpolated percentile (the standard definition) rather than a nearest-rank sample, so the value can differ slightly for identical data.
- Replaying a message now applies the currently configured
RetryPolicy:MaxRetriesrather than the original message's cap.
Quick Start
docker pull voyvodka/webhook-engine:0.4.0 docker compose up -dLinks
- Docker Hub: https://hub.docker.com/r/voyvodka/webhook-engine
- NuGet: https://www.nuget.org/packages/WebhookEngine.Sdk
- Documentation: https://webhook.sametozkan.com.tr
- Full changelog: https://github.com/voyvodka/webhook-engine/blob/main/CHANGELOG.md
- Authenticated SignalR delivery hub.
- WebhookEngine v0.3.1Open on GitHub
WebhookEngine v0.3.1
Maintenance patch — a runtime base-image security refresh that clears 16 container CVEs (1 critical) reported by Docker Scout, plus a full dependency refresh across the dashboard, the
@webhookengine/endpoint-managerpackage, and backend NuGet. No user-visible behaviour changes and no breaking changes; thev1route prefix, the Standard Webhooks signature surface, and theWebhookEngine.Sdkpublic API are unchanged.Features / Fixes / Changes
Security
- Runtime base image refresh clears 16 container CVEs (Docker Scout).
mcr.microsoft.com/dotnet/aspnet:10.0-alpinerepinnedf03685b → 57bd717(Alpine3.23.4 → 3.23.5, OpenSSL3.5.6-r0 → 3.5.7-r0), clearing the 16 CVEs Docker Scout reported against the publishedvoyvodka/webhook-engine:latestimage — 1 critical (CVE-2026-34182, CVSS 9.1), 8 high, 5 medium, 2 low — all in the Alpine base layer (OpenSSL plus one BusyBox advisory), none in application code (Scout's layer view showed our own layers at 0/0/0). The rebuilt image scans clean (0 vulnerabilities under Trivy). The build-stagemcr.microsoft.com/dotnet/sdk:10.0is also repinned548d93f → ea8bde3for Dependabot tracking — build-only, never ships in the final image.
Changed
- Frontend dependency refresh. Dashboard bundle:
react-router7.17.0 → 8.0.1(major; v8's only hard breaking change is removing thereact-router-domre-export, which the dashboard never used),lucide-react1.18.0 → 1.22.0,recharts3.8.1 → 3.9.0. Dev / build tooling:vite8.0.16 → 8.1.0,@vitejs/plugin-react6.0.2 → 6.0.3,@types/node25.9.3 → 26.0.1,eslint10.5.0 → 10.6.0, andtypescript-eslint8.61.0 → 8.62.0(dashboard and@webhookengine/endpoint-manager); plusvite8.0.11 → 8.0.16insamples/portal-host. - Backend dependency refresh.
OpenTelemetry.Instrumentation.AspNetCore1.15.2 → 1.16.0andScalar.AspNetCore2.16.4 → 2.16.6(runtime);Microsoft.NET.Test.Sdk18.6.0 → 18.7.0(test projects — build-only, no runtime impact).
Quick Start
docker pull voyvodka/webhook-engine:0.3.1 git clone https://github.com/voyvodka/webhook-engine.git cd webhook-engine docker compose -f docker/docker-compose.yml up -dDashboard at http://localhost:5100 — login
[email protected]/changeme(reset before exposing publicly).Links
- Docker Hub: voyvodka/webhook-engine:0.3.1 (multi-arch:
linux/amd64+linux/arm64) - NuGet: WebhookEngine.Sdk 0.3.1
- Docs: docs/ — API, DATABASE, ARCHITECTURE, PORTAL, GETTING-STARTED, SELF-HOSTING, RELEASE
- Landing: webhook.sametozkan.com.tr
- Full changelog: CHANGELOG.md — 0.3.1 section
- Runtime base image refresh clears 16 container CVEs (Docker Scout).
- WebhookEngine v0.3.0Open on GitHub
WebhookEngine v0.3.0
SDK feature expansion, a breaking realignment of three response-model properties, full SDK test coverage (32 new cases), portal client correctness fixes, and a hardened CI/security baseline. Test suite grows from 280 to 312. Breaking change for SDK consumers reading
EndpointResponse.CustomHeadersJson,EndpointResponse.MetadataJson, orMessageAttemptResponse.RequestHeadersJsonasDictionary<string,string>?— those properties now returnJsonElement/JsonElement?to match the wire format. Thev1route prefix and Standard Webhooks signature surface are preserved.Features / Fixes / Changes
Added
EndpointClient.TestAsyncin the SDK:EndpointClient.TestAsync(endpointId, request)covers the one live/api/v1/*route that had no SDK binding. Returns the live response and the exact signed request via new modelsTestEndpointRequest,EndpointTestResult, andEndpointTestRequestPreview.- SDK request models now expose all API fields:
CreateEventTypeRequest/UpdateEventTypeRequest.IdempotencyWindowMinutesandCreateEndpointRequest/UpdateEndpointRequest.{AllowedIps, TransformExpression, TransformEnabled}— previously these fields were accepted by the API but unreachable from the SDK. - First
WebhookEngine.Sdk.Testsproject (32 cases): coversWebhookVerifierconstant-time HMAC across tolerance, secret-encoding (whsec_vs base64), multi-signature, tamper, and missing-field cases, plus a stub-HttpMessageHandlercontract suite that deserializes real API envelopes through the client so response-DTO drift now fails CI.
Changed
- SDK response models realigned with the engine DTOs (breaking for three properties):
EndpointResponse.CustomHeadersJson/MetadataJsonandMessageAttemptResponse.RequestHeadersJsonchange fromDictionary<string,string>?toJsonElement/JsonElement?. The old dictionary type silently dropped the entire field for any non-string value; the new types match the wire format exactly.EndpointResponsealso gainsAllowedIps,TransformExpression,TransformEnabled,TransformValidatedAt;EventTypeResponsegainsIdempotencyWindowMinutes— all were sent on the wire but silently dropped before this release. - Concurrency regression tests on real PostgreSQL: new Testcontainers tests cover the idempotency UNIQUE race (
23505on N concurrent inserts of the same key), theFOR UPDATE SKIP LOCKEDdequeue (K workers never double-claim), and theMark*AsyncCAS guard. Worker helper methods are nowinternaland exercised directly so a production regression fails CI rather than silently passing a logic copy.
Fixed
@webhookengine/endpoint-managerportal client realigned: three concrete defects against a real engine —updateEndpoint()sentPUTinstead ofPATCH(every update failed); the client read a non-existentisActiveflag instead of the engine'sstatusstring (badge always showed "Disabled"); it readcustomHeadersinstead ofcustomHeaderNames(silently wiped headers on every save). Types now mirror the engine; a contract test prevents this class of drift from shipping again.- Dependabot lockfile-sync now re-triggers CI:
sync-bun-lock.ymlpreviously pushed withGITHUB_TOKEN, which GitHub's recursion guard blocks from triggering new runs — leaving PRs blocked on required checks until manual close/reopen. The push now uses a short-lived GitHub App installation token so the sync commit re-triggers checks automatically. - Dependabot lockfile auto-sync extended to all workspace members: the
pull_request_targettrigger previously only watchedsrc/dashboard/package.json; bumps inpackages/endpoint-manager/package.jsonsilently left a stalebun.lockand broke CI. - Documentation accuracy pass: README
docker runexample corrected (ConnectionStrings__Default, port5100);docs/PRD.md,docs/ROADMAP.md,docs/ARCHITECTURE.md, andAGENTS.mdsynced to current state.
Security
- Repository security baseline hardened: added
SECURITY.mdwith supported-versions policy and private-vulnerability-reporting flow. Added explicitpermissions: contents: readtoci.ymlandrelease.yml. Extended Dependabot to the/packages/endpoint-managernpm workspace. Addedtimeout-minutesto CI jobs. - Secret-scanning false-positive suppression:
.github/secret_scanning.ymladdspaths-ignorefordocs/**,samples/**,tests/**, and**/*.md— WebhookEngine'swhsec_examples collide with the Stripe webhook-secret pattern; source paths remain scanned.
Quick Start
docker pull voyvodka/webhook-engine:0.3.0 git clone https://github.com/voyvodka/webhook-engine.git cd webhook-engine docker compose -f docker/docker-compose.yml up -dDashboard at http://localhost:5100 — login
[email protected]/changeme(reset before exposing publicly).Links
- WebhookEngine v0.2.2Open on GitHub
WebhookEngine v0.2.2
Maintenance patch — a full dependency refresh across NuGet, npm, Docker base images, and GitHub Actions, plus a CI fix that realigns the Bun toolchain (
1.2.x → 1.3.x) and repairs the Dependabot lockfile auto-sync. No user-visible behaviour changes and no breaking API changes; thev1route prefix and Standard Webhooks signature surface are unchanged.Features / Fixes / Changes
Security
- Docker base image digest bumps.
dotnet/aspnet(60eb031→1e37a82),dotnet/sdk(8a90a47→dc8430e), andoven/bun(4de4753→5acc90a) refreshed to pick up the latest patched Alpine layers in the published image.
Changed
- NuGet runtime dependency refresh.
Microsoft.AspNetCore.OpenApi,Microsoft.EntityFrameworkCore(+.Relational,.Design),Microsoft.Extensions.Http,.Hosting.Abstractions, and.DependencyInjectionbumped10.0.7 → 10.0.8;Scalar.AspNetCore2.14.11 → 2.14.14. Test-only:FluentAssertions8.9.0 → 8.10.0,coverlet.collector10.0.0 → 10.0.1,Microsoft.AspNetCore.Mvc.Testing/EntityFrameworkCore.InMemory10.0.7 → 10.0.8,Testcontainers.PostgreSql4.11.0 → 4.12.0. - npm / frontend dependency refresh.
lucide-react1.14.0 → 1.16.0,react-router7.15.0 → 7.15.1,vite8.0.11 → 8.0.13,@vitejs/plugin-react6.0.1 → 6.0.2,eslint10.3.0 → 10.4.0,typescript-eslint8.59.2 → 8.59.3,@types/node25.6.2 → 25.8.0. (lucide-react,react-router, andviteship in the dashboard bundle; the rest are dev/build tooling.) - GitHub Actions version bumps.
actions/dependency-review-actionv4 → v5;actions/setup-nodev5 → v6.
Fixed
- CI Bun toolchain aligned to 1.3.x and the Dependabot lockfile auto-sync repaired.
ci.yml,sync-bun-lock.yml, andpublish-portal.ymlpinnedBUN_VERSION: "1.2.x"while the committedbun.lockis in Bun 1.3 text-lockfile format, the Docker dashboard build runsoven/bun:1, and local dev runs 1.3.x. The skew failed every grouped Dependabot npm bump attsc --noEmitunder 1.2.x's transitive resolution (a duplicate vitePlugintype identity invite.config.ts). Separately, the lockfile-sync step ranbun install --no-save, which never writesbun.lock, so the auto-sync silently no-op'd and frontend dependency PRs landed with stale lockfiles that brokebun install --frozen-lockfile. All three pins are now1.3.xand the sync step writes the refreshed lockfile back to the PR branch.
Quick Start
docker pull voyvodka/webhook-engine:0.2.2 git clone https://github.com/voyvodka/webhook-engine.git cd webhook-engine docker compose -f docker/docker-compose.yml up -dDashboard at http://localhost:5100 — login
[email protected]/changeme(reset before exposing publicly).Links
- Docker Hub: voyvodka/webhook-engine:0.2.2 (multi-arch:
linux/amd64+linux/arm64) - NuGet: WebhookEngine.Sdk 0.2.2
- Docs: docs/ — API, DATABASE, ARCHITECTURE, PORTAL, GETTING-STARTED, SELF-HOSTING, RELEASE
- Landing: webhook.sametozkan.com.tr
- Full changelog: CHANGELOG.md — 0.2.2 section
- Docker base image digest bumps.
- WebhookEngine v0.2.1Open on GitHub
WebhookEngine v0.2.1
Patch release closing the v0.2.0 portal audit follow-up: three P0 security hardening fixes, 23 new tests filling the portal coverage gaps, four P1 behaviour corrections, two ADRs locking in portal architecture decisions, full API and architecture documentation for the portal stack, and build hygiene (Docker Hub sync hard-failure, comment-noise removal).
Features / Fixes / Changes
Security
- Portal rate-limit enforcement on mutating routes.
PortalEndpointsControllernow carries[EnableRateLimiting("send-by-appid")]at the controller level; a leaked portal token could previously spam/test(real outbound HTTP POST) without sharing the per-tenant rate-limit budget. - JWT parser size cap — DoS amplification path closed.
PortalTokenAuthMiddlewarenow rejects Bearer payloads larger than 8 KiB before the JWT parser runs (down from the .NET default ~250 KiB); oversized tokens return401immediately. PortalLookupCacheatomic CTS swap — race window closed.Setnow usesAddOrUpdateto atomically swap and dispose the previousCancellationTokenSource, preventing a racingInvalidateApplicationfrom binding a fresh cache entry to a disposed token.- Portal CORS preflight deny-cache.
PortalCorsMiddlewarenow caches both allow and deny outcomes for the signing-key lookup TTL (default 60 s), removing a low-effort DB hammer vector from repeatedOPTIONSagainst disallowed origins.
Behaviour
PATCH /api/v1/portal/endpoints/{id}replacesPUT. The route's partial-replace semantics were alwaysPATCH; the[HttpPut]attribute was a mislabel. The<EndpointManager />component already issuesPATCH.- Portal disable preserves
AllowedPortalOriginsJson. Disabling an app's portal now only revokes the signing key; the operator-curated CORS allowlist is kept so a re-enable does not require re-entering origins. - Validator drift consolidated via
EndpointValidationRules. Six shared extension methods replace per-validator duplicates across the four admin and two portal endpoint validators — a single source of truth for rule tightening going forward. - npm publish workflow (
publish-portal.yml). Fires onportal-v*tags; publishes@webhookengine/endpoint-managerwith sigstore provenance and aprivate:trueguard.
Tests
- 23 new tests closing v0.2.0 portal coverage gaps.
PortalCorsMiddlewareTests(7 facts),PortalLookupCacheTests(5 facts),PortalOriginsAllowlistE2ETests(7 facts, Testcontainers against real PostgreSQL JSONB), plus cross-tenant guard and empty-capabilities defense-in-depth facts inPortalEndpointsControllerTests. Total test count: 279.
Docs
docs/API.md§3.8 — Portal API reference. Covers HS256 JWT contract, capability scopes, per-app CORS, every/api/v1/portal/*route, the portal-specific error code table, and an end-to-end Node.js + cURL probe.docs/ARCHITECTURE.md§4.3 — Portal token authentication. Documents middleware ordering,PortalLookupCacheTTL + atomic-CTS-swap behaviour, and JWT validator defense-in-depth choices.- ADR-004 — Portal signing key storage. Locks in the plaintext
varchar(64)decision, no-grace rotation lifecycle, and one-shot reveal contract. - ADR-005 — Portal CORS preflight deny-cache TTL. Locks in the
PortalAuth:LookupCacheTtlSeconds-symmetric TTL and documents why no synchronous invalidation hook is needed. docs/RELEASE.md§1 updated.DOCKERHUB_TOKENnow documents all three required scopes to eliminate the "release ran but Docker Hub overview is stale" debugging session.
Infrastructure / Build
release.ymlcontinue-on-errorworkaround removed. TheSync Docker Hub descriptionstep now hard-fails on scope misconfiguration instead of silently succeeding.samples/portal-host/reference application added;docs/PORTAL.md§5 component usage section completed.- Section-header comment noise removed (~30 lines of banner separators with no WHY content cleared from controllers and SDK models).
Quick Start
docker pull voyvodka/webhook-engine:0.2.1 git clone https://github.com/voyvodka/webhook-engine.git cd webhook-engine docker compose -f docker/docker-compose.yml up -dDashboard at http://localhost:5100 — login
[email protected]/changeme(reset before exposing publicly).Links
- Docker Hub: voyvodka/webhook-engine:0.2.1 (multi-arch:
linux/amd64+linux/arm64) - NuGet: WebhookEngine.Sdk 0.2.1
- Docs: docs/ — API, DATABASE, ARCHITECTURE, PORTAL, GETTING-STARTED, SELF-HOSTING, RELEASE
- Landing: webhook.sametozkan.com.tr
- Full changelog: CHANGELOG.md — 0.2.1 section
- Portal rate-limit enforcement on mutating routes.
- @webhookengine/endpoint-manager v0.1.0Open on GitHub
@webhookengine/endpoint-manager v0.1.0
Initial public release of the embeddable customer portal React component for WebhookEngine. Pairs with engine v0.2.0+ (which exposes the
/api/v1/portal/*route group). ESM-only, peer depsreact ^19andreact-dom ^19, zero runtime dependencies, ~14.2 KB gz JS + ~4.3 KB gz CSS.Features / Fixes / Changes
Added
<EndpointManager />: the headline embeddable React component. Wraps a self-contained portal that authenticates against a host SaaS-minted HS256 JWT and serves a customer-facing endpoint management UI. Props:baseUrl,token,appId,capabilities,theme,className,onError,onUnauthorized.<EndpointList />: paginated table of endpoints with status badges, capability-gated[+ New endpoint]plus per-row Edit / Enable / Disable / Delete / Test / Attempts actions.<EndpointEditor />: modal-style overlay for create + edit. URL (HTTPS), description, custom headers (key/value editor), event-type filter, secret override (whsec_prefix + 32+ char client-side check). Server validationfieldErrorsroute to per-field inline messages. Field narrowing enforced at the DTO level —transformExpression/transformEnabled/allowedIpsJsonare silently dropped on write.<EndpointTester />: modal opened from the Test row action. JSON payload validation on blur, color-coded response panel (status + latency + body, collapsed if >500 chars), collapsible signed-request preview showing the URL + headers (webhook-id/webhook-timestamp/webhook-signature) + body the receiver actually HMAC-verifies.<AttemptList />: modal opened from the Attempts row action. Paginated delivery history with relative + absolute timestamps, success / failure status badges, HTTP code, latency, expandable response excerpts.createPortalClient(): fetch wrapper, zero runtime dependencies. Bearer auth,ApiEnvelopeunwrap, 4xx/5xx →PortalErrorwithcode+status+fieldErrors, 401 hooksonUnauthorizedfor token re-mint flows. Methods:listEndpoints,getEndpoint,createEndpoint,updateEndpoint,deleteEndpoint,enableEndpoint,disableEndpoint,testEndpoint,listAttempts,listEventTypes.PortalCapabilityunion and full TypeScript types forPortalAppState,PortalEndpointSummary,PortalEndpointDetail,PortalAttempt,PortalTestResult,PortalListResult<T>,PortalError,PortalClientOptions,EndpointManagerProps.- Tailwind 4 internal compile pipeline:
dist/style.cssships pre-compiled with the package. The@themeblock defines--color-whe-*tokens (background, text, border, accent, success, danger, warning) — consumers override at:rootor.whe-portalscope to re-theme without touching component code. - 42-test vitest suite covering the client contract, capability gating, field-narrowing, JSON validation, secret-override entropy floor, signed-request preview, status badge color-coding, and pagination boundaries.
samples/portal-host/reference app in the engine repo demonstrating consumer integration: Vite + mocked fetch + browser-side JWT mint (DEMO ONLY — production minting belongs on the host SaaS's own backend).
Notes
- Engine compatibility: v0.2.0 or later. Earlier engines lack the
/api/v1/portal/*surface. - JWT requirements: HS256, signed with the per-app
PortalSigningKey(rotated from the engine's operator dashboard). Lifetime cap defaults to 15 min on the engine side. The component never sees the signing key — only the bearer token. - Provenance: Published with sigstore attestation via
--provenance.
Links
- WebhookEngine v0.2.0 — embeddable customer portal (engine half)Open on GitHub
WebhookEngine v0.2.0
The first minor release. Adds an embeddable customer-facing portal: SaaS operators can now hand customers a self-service
<EndpointManager />React component that runs against a narrowed/api/v1/portal/*API surface, scoped per-application via short-lived HS256 JWTs minted by the host SaaS backend. The engine never mints these tokens — it only verifies them — and the per-app signing key is generated, rotated, and revoked from the operator dashboard. No breaking API changes — thev1route prefix and Standard Webhooks signature header names are preserved. Test count moved from 215 to 252.Features / Fixes / Changes
Added
- Embeddable customer portal — engine half (B1 Steps 2-4): new
Application.PortalSigningKey(HS256 secret, 64-char varchar) andApplication.AllowedPortalOriginsJson(JSONB) columns; newPortalTokenAuthMiddlewarevalidates short-lived HS256 JWTs (algorithm-pinned, 15-minute lifetime cap, capability-scoped viaendpoints:read|write|testandattempts:read); newPortalCorsMiddlewaredoes per-application dynamic CORS with RFC 6454-compliant ordinal-case-insensitive origin matching; new/api/v1/portal/*route group exposes a narrowed CRUD-and-test surface that silently strips admin-only fields (transformExpression,allowedIpsJson) on writes and never returns the signing key. - Embeddable customer portal — operator dashboard (B1 Step 5): new
DashboardPortalController(/api/v1/dashboard/applications/{appId}/portal/...) with 5 cookie-authed actions (read, enable, rotate, disable, update-origins). New<PortalAccessModal />React component opened from the Applications page row actions: enable / rotate / disable controls with show-once secret reveal, chip-list editor for allowed CORS origins, copy-paste embed snippet for the host SaaS. Audit log records every mutating action withPortalSigningKeyredacted to aportalEnabledboolean — the literal secret never enters the snapshot. Cache invalidation viaPortalLookupCache.InvalidateApplication(appId)after every mutating write so rotations take effect within milliseconds rather than within the 60-second cache TTL. Application.PortalRotatedAt: new column for surfacing "last rotated at" in the dashboard portal-management UI.MessageRepository.ListAttemptsByEndpointAsync/CountAttemptsByEndpointAsync: drives the portal's per-endpoint attempt history feed; uses the existingidx_attempts_endpoint_statuscovering index, no new migration.- Bun workspaces (B1 Step 1): root
package.jsondeclares["src/dashboard", "packages/*"]so the upcoming@webhookengine/endpoint-managerpackage can land atpackages/endpoint-manager/without a second migration. Singlebun.lockat the workspace root; Dockerfile and CI workflow updated to follow.
Changed
AuditLogsControllerno longer bypasses the repository pattern: newAuditLogRepository.ListAsync(...)carries the filter chain and pagination; the controller keeps the JSON hydration since that is HTTP response-shaping, not persistence. Behavior unchanged.- Dependabot npm PRs auto-sync
bun.lockvia a newpull_request_target-triggered workflow gated ongithub.actor == 'dependabot[bot]'. Eliminates the manualbun install + commit + pushthat every minor / patch frontend bump previously required. - Documentation drift sync:
CLAUDE.mdandREADME.mdstack lines updated to matchsrc/dashboard/package.json(TypeScript 6 / Vite 8 / TanStack Query 5; previous wording said TypeScript 5.9 / Vite 7). ADR-003 (payload transformation) flipped from Proposed to Accepted with an Implementation section recording the three-phase rollout that shipped in v0.1.4. - Dependency refresh:
tailwindcssand@tailwindcss/vite4.2.4 → 4.3.0 (with the transitive@tailwindcss/nodeand@tailwindcss/oxideplatform binaries).
Security
- HS256-only algorithm allowlist on portal JWTs:
ValidAlgorithms = [HmacSha256]is enforced viaMicrosoft.IdentityModel.Tokens8.17.0;alg=noneandalg=HS384/HS512tokens are rejected withPORTAL_AUTH_INVALID_SIGNATURE. The catch-ladder absorbs algorithm-rejection exceptions without echoing the rejected algorithm name in the error response. - Per-app dynamic CORS with explicit allowed-origins enumeration (no wildcards);
PortalCorsMiddlewareechoes the validated requestOrigin(never*) and is RFC 6454-compliant case-insensitive on host comparisons. - App-scope isolation across the portal surface: every portal route reads
AppIdfrom the JWT, never from query / body / route. Cross-tenant probes return404 PORTAL_NOT_FOUND(not 403) so the response shape doesn't leak the existence of cross-tenant resources. SecretOverrideentropy floor on portal writes: the portalCreate/Updateendpoint validators require thewhsec_prefix and a 32-128 char range so a customer cannot silently downgrade their HMAC secret topassword123.- Audit redaction:
DashboardPortalControllerwrites audit-log snapshots withPortalSigningKeyreduced to a booleanportalEnabledflag; the literal secret never entersbefore_json/after_json. Verified by a load-bearing negative test that scans the column forwhsec_after a real enable call.
Quick Start
docker pull voyvodka/webhook-engine:0.2.0 docker compose -f docker/docker-compose.yml up -dThe app starts on
http://localhost:5100. Dashboard login:[email protected]/changeme. Portal access for an application is enabled from the dashboard's Applications page → row actions → Portal access.Links
- Embeddable customer portal — engine half (B1 Steps 2-4): new
- WebhookEngine v0.1.6Open on GitHub
WebhookEngine v0.1.6
Feature & polish cut covering eight new capabilities (per-resource overrides, IP allowlist, audit log, endpoint test webhook, SignalR endpoint health, validate-time URL guard), three rounds of dashboard polish (a11y, UX, TanStack Query data layer), three reviewer-finding fixes (transient DNS retry, cascade delete, polling debounce), and a backend correctness pass on the IP allowlist matcher, application rate-limiter sweep, and endpoint health tracker. No breaking API changes — the
v1route prefix and Standard Webhooks signature surface are preserved. Test count moved from 211 to 215.Features / Fixes / Changes
Added
- Endpoint test webhook (F1):
POST /api/v1/dashboard/endpoints/{id}/testfires a customizable, fully-signed webhook to the endpoint URL without enqueueing a realMessage, and returns the receiver's response plus the exact request that was sent. Dashboard endpoint editor carries a Send test drawer. - Per-application rate-limit override (F6):
Application.RateLimitPerSecond1-second sliding-window override complements the per-endpoint per-minute gate; idle-evicted at 15 minutes. - Per-application retention overrides (F3):
Application.RetentionDeliveredDaysandRetentionDeadLetterDaysoverrideWebhookEngine:Retentiondefaults per tenant; the cleanup worker partitions its sweep accordingly. - Per-event-type idempotency window (F4):
EventType.IdempotencyWindowMinutesoverrides the per-app default for tighter or looser dedupe per event family. - Per-endpoint IP allowlist (F8):
Endpoint.AllowedIpsJsoncarries a CIDR positive-list (IPv4 + IPv6); deliveries only proceed when every resolved address sits inside at least one allowed CIDR. - Append-only audit log (F9): Admin actions across applications, endpoints, event types, replay, retry, and rotate-key write a forensic row to the new
audit_logstable with before / after snapshots andrequest_id.GET /api/v1/dashboard/auditexposes a paginated, filterable view. The table holds no FKs — rows survive cascades. - SignalR endpoint health channel (F7):
DeliveryHubbroadcastsEndpointHealthChanged(endpointId, status, circuitState, consecutiveFailures, cooldownUntilUtc)wheneverEndpointHealthTrackermutates an endpoint's circuit or visible status. DashboardEndpointsPageconsumes the event and invalidates its cache. - TanStack Query dashboard data layer (F12): Every dashboard page (Dashboard, Messages, Applications, Endpoints, EventTypes, DeliveryLog) routes server data through
useQuery/useMutation. ManualsetIntervalpolling and the smart-debounce shim are gone in favor of cache-aware refetching driven by SignalR invalidation.EndpointsPageinitial chunk drops from ~1.5 MB to ~20 kB (CodeMirror lazy-loaded).
Changed
- Validator chain rejects private-IP endpoint URLs at create / update (F2). Same SSRF rules already enforced at delivery (
ConnectCallback) now run at validate time too — a misconfigured URL is rejected before the row exists. - Modal a11y, mobile, dvh (DPR-1 + DPR-3):
role="dialog", ARIA wiring, focus trap,max-h-[85dvh], mobile filtermd:grid-cols-3, URL field server-side errors as field-scoped messages, payload editor errors split out, SignalR Live / Offline pill in the EndpointsPage header. - Dashboard consolidation (DPR-2): Shared
StatusBadge,inputClasses, and CodeMirroreditorTheme. CodeMirror isReact.lazy()— the 1.5 MB chunk loads only when the endpoint editor mounts.parseErrorreturns anApiErrorwith optionalfieldErrors. - Backend polish (R2 + R4 + R5):
IpAllowlistMatcher.AllAddressesAllowedshort-circuits empty allowlists before the empty-resolution deny branch (load-bearing ordering).ApplicationRateLimiter._lastSweepTicksisVolatile.Read+ CAS so torn 32-bit reads can't spawn back-to-back sweeps.EndpointHealthTracker.WithEndpointLockAsyncno longer double-fetches the endpoint row. - Dependency refresh: Backend —
Scalar.AspNetCore2.14.10 → 2.14.11,coverlet.collector8.0.1 → 10.0.0. Frontend —react/react-dom/react-is19.2.5 → 19.2.6,react-router7.14.2 → 7.15.0,@codemirror/view6.41.1 → 6.42.1,vite8.0.10 → 8.0.11, plus@tanstack/react-query5.100.9 (drives F12).
Fixed
- Transient DNS failures retry within budget (R1):
SocketException/ArgumentExceptionfrom the IP-allowlist resolution now route throughMarkFailedForRetryAsyncinstead of dead-lettering on first miss; only after the retry budget is exhausted does the message dead-letter. - Application / endpoint deletion cascades to bound messages (R3):
Message → ApplicationandMessage → Endpointforeign keys carryON DELETE CASCADE. Migration20260508081704_CascadeMessageDeleteOnAppAndEndpointis hand-written SQL because EF doesn't diffOnDeletechanges. - Modal focus trap, awaited refetches, SignalR cache invalidation on reconnect (DPR-1): Skeleton loaders carry
aria-busy="true"; mutating actions await their refetch before closing modals;useDeliveryFeed.onreconnectedresetslastHealthChangeso a stale event from before the disconnect doesn't double-fire. - Smart-debounced dashboard polling (R6) folded into F12: SignalR events now invalidate cache keys; TanStack Query handles the refetch cadence.
Security
- Endpoint URL DNS resolution at validator chain (F2): Same private-IP rules already enforced at delivery time now run at create / update — a misconfigured URL is refused before the row exists.
- Per-endpoint IP allowlist (F8): Opt-in CIDR positive-list at delivery time; transient resolver failures retry within the message's normal budget (R1) so flaky DNS doesn't cascade into dead-letter floods.
- Append-only audit log (F9): Forensic trail of admin actions; rows survive cascades for post-incident reconstruction.
Quick Start
docker pull voyvodka/webhook-engine:0.1.6 git clone https://github.com/voyvodka/webhook-engine.git cd webhook-engine docker compose -f docker/docker-compose.yml up -dDashboard at http://localhost:5100 — login
[email protected]/changeme(reset before exposing publicly).Links
- 🐳 Docker Hub:
voyvodka/webhook-engine:0.1.6(multi-arch:linux/amd64+linux/arm64) - 📦 NuGet:
WebhookEngine.Sdk 0.1.6 - 📚 Docs:
docs/— API, DATABASE, ARCHITECTURE, GETTING-STARTED, SELF-HOSTING, RELEASE - 🏠 Landing: webhook.sametozkan.com.tr
- 📋 Full changelog:
CHANGELOG.md— 0.1.6 section
- Endpoint test webhook (F1):
- WebhookEngine v0.1.5Open on GitHub
WebhookEngine v0.1.5
Post-audit hardening release. A multi-agent deep audit covered security, memory, concurrency, code quality, frontend, operations, timezone correctness, and NuGet SDK compliance; the resulting fixes (F1–F10) plus an idempotency race fix (F7) and an SDK target-framework simplification all land here. No breaking API changes — the
v1route prefix and Standard Webhooks header names are preserved.Features / Fixes / Changes
Added
- health probes:
/health/live(process up) and/health/ready(AppReadinessGate+DbContext.CanConnectAsync) for orchestrators (F5) - observability: OpenTelemetry tracing with optional OTLP export via
OpenTelemetry:OtlpEndpoint(F5) - sdk:
WebhookVerifierfor Standard Webhooks signature verification —FixedTimeEquals, 5-min default tolerance,whsec_/ base64 secrets, multi-value signatures (F9)
Changed
- sdk: target framework simplified from
net10.0;net9.0;net8.0tonet10.0only — pre-v1.0cleanup, NuGet badge flips to.NET 10.0 - memory:
IMemoryCachesize-bounded (SizeLimit = 10_000) with per-app cancellation-token-source invalidation (F4) - shutdown:
HostOptions.ShutdownTimeout = 45sso in-flight HTTP deliveries can drain on SIGTERM (F10) - security: dashboard admin default credentials rejected at startup outside Development (F5)
Fixed
- idempotency race: UNIQUE partial index on
(app_id, endpoint_id, idempotency_key)+ Stripe-style 23505-replay + retention NULL-out for window reuse (F7) - duplicate attempts on lock loss: CAS guards on
Mark{Delivered,FailedForRetry,DeadLetter}Asyncreturningbool(F2) - circuit-breaker race:
EndpointHealthTrackermutations serialized viapg_advisory_xact_locknamespace100_001(F3) - frontend:
webhookengine:auth-expiredCustomEventon 401 +RouteErrorBoundaryforChunkLoadErrorafter deploys (F8) - memory leaks:
HttpResponseMessagedisposal +EndpointRateLimiteridle-window eviction (15-minIdleAfter, 5-min sweep) (F9) - migration race: startup migration block wrapped in
pg_advisory_locknamespace200_000(F5)
Security
- SSRF + DNS rebinding:
PrivateIpDetectorblocks RFC1918/loopback/link-local/CGNAT/IPv6 unique-local;SocketsHttpHandler.ConnectCallbackpins resolved IP (F1) - headers + metrics gate: HSTS / CSP / X-Frame-Options DENY / X-Content-Type-Options / Referrer-Policy / Permissions-Policy;
/metricsBearer-token auth viaWebhookEngine:Metrics:ScrapeToken; cookieSecurePolicy = Alwaysoutside Dev/Testing (F6) - custom headers:
CustomHeaderPolicyrejects reserved headers (Authorization,Cookie,Host,Content-*,webhook-*), strips CR/LF, bounds size (F10)
Migration
One auto-applying migration ships in this release:
20260505140607_AddIdempotencyUniqueIndex. It NULL-outs any pre-existing duplicate(app_id, endpoint_id, idempotency_key)triples (keeping the most-recent row per group) before creating the unique index, so it cannot fail on legacy data.Quick Start
docker pull voyvodka/webhook-engine:0.1.5 docker compose upLinks
- health probes:
- v0.1.4 — Payload Transformation & Multi-archOpen on GitHub
WebhookEngine v0.1.4
ADR-003 (payload transformation) shipped end-to-end, OpenAPI/Scalar reference surface, NuGet brand icon, Docker Hub overview sync, and the multi-arch image (linux/amd64 + linux/arm64). Alpine base layers refreshed to clear 7 of the 11 Docker Scout CVEs against v0.1.3.
Added
- Payload transformation (ADR-003) — full rollout: per-endpoint JMESPath expressions reshape the body before signing and POSTing. Schema (Phase 1), delivery integration with timeout + output-size guards (Phase 2), and a CodeMirror 6-powered dashboard editor with live
POST /api/v1/dashboard/transform/validatepreview (Phase 3). Fail-open contract — any error falls back to the original payload. - OpenAPI document + Scalar interactive reference:
/openapi/v1.json+/scalarUI, mapped only in Development and Staging. - NuGet brand icon:
WebhookEngine.Sdknow ships with the project mark embedded. - Docker Hub overview sync: the GitHub README is now synced into the Docker Hub repository overview tab on every release.
- Security automations: CodeQL (csharp + js-ts), Dependency Review, Dependabot for NuGet/npm/GitHub Actions/Docker base images.
Fixed
- Multi-architecture Docker image:
linux/amd64+linux/arm64manifest list. Previous releases were amd64-only — Apple Silicon Macs and arm64 Linux servers can nowdocker pullcleanly. - Removed phantom
unknown / unknownrow on Docker Hub: provenance + SBOM attestations explicitly disabled so the tag listing shows only the real platforms.
Security
- Alpine base image refresh: openssl/libcrypto3/libssl3
3.5.5-r0→3.5.6-r0(1 critical + 5 high CVEs cleared) and musl1.2.5-r21→1.2.5-r23(1 high CVE cleared) via Docker Scout. All Dockerfile FROMs now SHA-pinned for Dependabot tracking. - Log-forging hardening: the JMESPath transformer now sanitizes user-supplied expressions before logging (4
cs/log-forgingalerts resolved).LogSanitizermoved toWebhookEngine.Core.Utilitiesso both API and Infrastructure can consume it.
Changed
- Frontend toolchain: dashboard package manager migrated from Yarn to Bun 1.2+.
Removed
WebhookEngine.Applicationproject: empty since the CQRS scaffold removal in v0.1.0; cleaned up across the solution and ADR-002.
Quick Start
docker pull voyvodka/webhook-engine:0.1.4 # amd64 + arm64 # or docker pull voyvodka/webhook-engine:latest cd docker && docker compose up -dLinks
- Payload transformation (ADR-003) — full rollout: per-endpoint JMESPath expressions reshape the body before signing and POSTing. Schema (Phase 1), delivery integration with timeout + output-size guards (Phase 2), and a CodeMirror 6-powered dashboard editor with live
- v0.1.3 — Landing Page & SDK AlignmentOpen on GitHub
WebhookEngine v0.1.3
Documentation and packaging update accompanying the new project landing page.
Changes
- landing page: new project site at webhook.sametozkan.com.tr — features, quick start, and links to all resources
- readme: website, Docker Hub, and NuGet links added to the header
- sdk: version aligned with main project (
0.1.3); package project URL updated to the landing page - docs: removed stale internal planning files (backlog-v0.1.1, triage-flow, typescript-sdk-demand-criteria)
Links
- Docker Hub —
docker pull voyvodka/webhook-engine:0.1.3 - NuGet —
dotnet add package WebhookEngine.Sdk --version 0.1.3 - Documentation
- v0.1.2 — Stabilization PatchOpen on GitHub
WebhookEngine v0.1.2
Bug fixes, dependency updates, and dashboard reliability improvements.
Fixes
- circuit-breaker limbo: Messages for circuit-open endpoints no longer get stuck in Pending forever — they now increment attemptCount and eventually dead-letter as expected
- FluentValidation migration: Replaced deprecated
FluentValidation.AspNetCorewith custom action filter usingFluentValidation.DependencyInjectionExtensions 12.1.1 - dashboard analytics: Fixed SQL alias casing in timeline query for EF Core mapping
- SignalR reconnection: Client now retries indefinitely with exponential backoff (capped at 30s) instead of giving up after 4 attempts
- delivery-log route: Removed broken parameterless
/delivery-lognav item that errored without a messageId
Chores
- NuGet: EF Core 10.0.5, removed unused Serilog/Http packages from Infrastructure and Worker
- Frontend: Vite 8, TypeScript 6, lucide-react 1.7, and other dependency upgrades
- Login page: Removed debug credentials, improved UX with monospace font and error animation
Links
- Docker Hub: voyvodka/webhook-engine
- NuGet: WebhookEngine.Sdk
- v0.1.1 — Stabilization PatchOpen on GitHub
WebhookEngine v0.1.1
Post-stabilization patch with documentation sync and a SQL mapping fix.
Fixes
- fix: PascalCase SQL aliases in
DashboardStatsRepositoryfor correct EF CoreSqlQueryRawproperty mapping
Documentation
- Updated
AGENTS.mdandARCHITECTURE.mdto reflect Phase 3 DashboardController split (4 controllers) and CQRS scaffold removal - Fixed dev port references in
CONTRIBUTING.md(5100 → 5128 fordotnet run)
Stabilization Summary (v0.1.0 → v0.1.1)
This release concludes the v0.1.0 stabilization milestone:
- 5 phases, 13 plans, 18/18 requirements satisfied
- Circuit breaker race condition fixed, message status state machine added
- Per-AppId rate limiting, configurable idempotency window
- DashboardController split into 4 single-responsibility controllers
- Dashboard overview query consolidated (9 queries → 1)
- 3 ADRs written (replay scope, CQRS removal, payload transformation)
- All 5 GitHub issues closed
Full Changelog: https://github.com/voyvodka/webhook-engine/compare/v0.1.0...v0.1.1
- fix: PascalCase SQL aliases in
- v0.1.0 — Initial ReleaseOpen on GitHub
WebhookEngine v0.1.0
First public release of WebhookEngine — a self-hosted webhook delivery platform.
Core Features
- Queue-based delivery — PostgreSQL SKIP LOCKED queue, no Redis/RabbitMQ needed
- Retry with backoff — 7 attempts with exponential backoff (5s → 24h)
- Circuit breaker — per-endpoint health tracking with automatic open/close via advisory locks
- HMAC signing — Svix-compatible Standard Webhooks signatures
- Rate limiting — per-application token bucket on public endpoints
- Configurable idempotency — per-app deduplication window (default 24h)
- Real-time dashboard — React 19 + SignalR live delivery monitoring
- Prometheus metrics — OpenTelemetry instrumentation built-in
- Single container — API + workers + dashboard in one Docker image
Quick Start
docker pull voyvodka/webhook-engine:0.1.0 docker compose -f docker/docker-compose.yml upDashboard:
http://localhost:5100— Login:[email protected]/changemeLinks