The open-source dev IDE for webhooks. Receive webhooks, store every request durably, and deliver them reliably — with a local tunnel, automatic retries, and full observability.
dstream sits between webhook senders (Stripe, GitHub, Shopify, your own services) and your app. It accepts inbound webhooks, persists every request, applies per-connection delivery + retry policy, and forwards to your endpoints — while you watch every attempt in a dashboard.
Status: Phase 1 (core inbound gateway) is shipped and security-hardened. Later phases (outbound, transforms, record/replay, visual builder) are on the roadmap below. PLAN.md is the live design doc.
You need Docker (Desktop / OrbStack / Colima). Nothing else.
cp .env.example .env # one-time; sane local defaults, dev login enabled
docker compose -f deploy/docker/docker-compose.yml up -d --buildThat single command builds and starts everything:
| Service | What it does | Address |
|---|---|---|
web |
Dashboard (Tanstack Start) | http://localhost:3000 |
server |
HTTP API + ingest endpoint | http://localhost:8080 |
worker |
Delivery worker + stuck-event reaper | — |
migrate |
Runs DB migrations automatically, then exits | — |
postgres |
State of record | host :5433 |
redis |
Queue + rate limit + cache | :6379 |
jaeger |
Trace collector + UI (OpenTelemetry) | http://localhost:16686 |
Migrations are automatic — the migrate service runs on every up and server/worker wait for it to finish, so the database is always current. You never run a migrate command by hand.
Check it's healthy:
docker compose -f deploy/docker/docker-compose.yml ps
docker compose -f deploy/docker/docker-compose.yml logs -f serverTraces: with the dev stack up, open the Jaeger UI at http://localhost:16686 and
select the dstream service to see a webhook's ingest→queue→delivery trace.
Tracing is off by default (DSTREAM_TRACING_ENABLED); the compose stack turns it
on and points it at the jaeger service. If you enable tracing without a
reachable OTLP collector, the server/worker still start but log periodic span
export errors — expected, not a crash.
Metrics: /metrics (Prometheus text format) is gated to super-admins because it
exposes tenant ids and names. It is therefore browse-only in Phase 1 — a logged-in
super-admin can view it, but no automated scraper ships in the dev stack (a stock
Prometheus can't present the session cookie).
- Open http://localhost:3000 and enter any email.
- In dev mode the magic-link URL is printed in the server logs (no SMTP needed) — copy it from the
logs -f serveroutput and open it. - You're in. A personal workspace is created automatically on first login.
docker compose -f deploy/docker/docker-compose.yml down # stop
docker compose -f deploy/docker/docker-compose.yml down -v # stop + wipe dataAfter signing in, create an API key in the dashboard (Settings → API Keys) and export it:
export DSTREAM_API_KEY=dsk_... # from the dashboard
API=http://localhost:8080Then wire up a source → destination → connection and fire a webhook:
# 1. Source (gives you an ingest token/URL)
SRC=$(curl -sX POST $API/api/sources \
-H "Authorization: Bearer $DSTREAM_API_KEY" -H "Content-Type: application/json" \
-d '{"name":"stripe-prod","type":"stripe"}')
SRC_ID=$(echo "$SRC" | jq -r .id); TOKEN=$(echo "$SRC" | jq -r .ingest_token)
# 2. Destination (where events get delivered)
DEST=$(curl -sX POST $API/api/destinations \
-H "Authorization: Bearer $DSTREAM_API_KEY" -H "Content-Type: application/json" \
-d '{"name":"echo","type":"http","url":"https://httpbin.org/anything","rate_limit_rps":5}')
DEST_ID=$(echo "$DEST" | jq -r .id)
# 3. Connect them
curl -sX POST $API/api/connections \
-H "Authorization: Bearer $DSTREAM_API_KEY" -H "Content-Type: application/json" \
-d "{\"source_id\":\"$SRC_ID\",\"destination_id\":\"$DEST_ID\"}"
# 4. Send a webhook
curl -sX POST $API/e/$TOKEN -H "Content-Type: application/json" -d '{"hello":"world"}'Open http://localhost:3000/events — the event appears, delivered, with every attempt recorded.
Prefer the CLI to bootstrap?
docker compose -f deploy/docker/docker-compose.yml exec server dstream admin bootstrap --email [email protected] --org acmecreates a user + org and prints an API key.
The headline dev feature: pipe live webhooks straight to a local server, no ngrok.
The dstream CLI runs on your machine (it forwards to your local port, so it can't run inside a container). Build it once — needs Go — or grab a release when available:
go build -o dstream ./cmd/dstream # then use ./dstream, or move it onto your PATH# a CLI-type destination + connection (once)
CLI_DEST=$(curl -sX POST $API/api/destinations \
-H "Authorization: Bearer $DSTREAM_API_KEY" -H "Content-Type: application/json" \
-d '{"name":"local","type":"cli"}')
curl -sX POST $API/api/connections \
-H "Authorization: Bearer $DSTREAM_API_KEY" -H "Content-Type: application/json" \
-d "{\"source_id\":\"$SRC_ID\",\"destination_id\":\"$(echo "$CLI_DEST" | jq -r .id)\"}"
# open the tunnel (uses your API key)
export DSTREAM_API_KEY=dsk_...
dstream cli listen --source stripe-prod --forward http://localhost:3001Now every webhook to that source is forwarded to localhost:3001, and the response is captured as the delivery attempt.
Teams that consume webhooks rebuild the same operational layer in every service: retries, backpressure, replay, a place to see what happened. dstream does it once.
- Providers retry on their own schedule — you find out about dropped events too late.
- One noisy sender swamps your handler — no built-in rate limiting or backpressure.
- Local development is
ngrok+ manual replays + hand-built fixtures. - Debugging a failed delivery means correlating a provider dashboard, your logs, and a queue tool nobody owns.
| Bet | What it is | Status |
|---|---|---|
| Best local dev loop | First-class CLI: tunnel, replay, fixture library. Test webhook handlers like unit tests. | Tunnel shipped; replay/fixtures planned |
| Visual workflow builder | Node-based UI: source → filter → transform → destination. | Planned (Phase 5) |
| Record / replay providers | VCR-style capture of live provider traffic for deterministic CI. | Planned (Phase 4) |
Combined positioning: the dev IDE for webhooks — OSS-first, self-hostable from one binary, SaaS-able from the same codebase.
| dstream | Hookdeck | Convoy | Svix | webhook.site | |
|---|---|---|---|---|---|
| Inbound gateway | ✅ shipped | ✅ | ✅ | partial | partial |
| Per-connection retry + RPS policy | ✅ shipped | ✅ | ✅ | ✅ | ❌ |
| CLI tunnel | ✅ shipped | basic | ❌ | ❌ | view only |
| OSS + self-host | ✅ | ❌ | ✅ | partial | ❌ |
| Outbound (publish) | planned (P2) | ✅ | ✅ | ✅ | ❌ |
| Record / replay fixtures | planned (P4) | ❌ | ❌ | ❌ | ❌ |
| Visual workflow | planned (P5) | ❌ | ❌ | ❌ | ❌ |
webhook sender
│ POST /e/{ingest_token}
▼
┌─────────┐ store request+body ┌──────────┐
│ ingest │ ──────────────────────► │ Postgres │
└────┬────┘ dedup + enqueue └──────────┘
│ fair-queue task
▼
┌─────────┐ ┌────────────────────────────────────────┐
│ Redis │◄──│ worker: rate-limit → deliver → retry │
│ (queue) │ │ ├─ HTTP destination (SSRF-guarded) │
└─────────┘ │ └─ CLI tunnel (WebSocket to your laptop)│
└────────────────────────────────────────┘
▲
dashboard (:3000) + admin queue stats (/admin/queues)
One Go binary, several subcommands (server, worker, cli, migrate, admin) — a modular monolith. Self-hosters run one container set; scale by running more server/worker replicas of the same image.
- Backend: Go, chi router, sqlc-generated Postgres access, Atlas-managed migrations.
- Queue: a custom Redis-backed per-org fair scheduler (
internal/dqueue) — round-robin across orgs, at-least-once via a processing lease + recoverer, own retry/backoff + dead-letter. Aggregate queue stats at/admin/queues. - Frontend: Tanstack Start (React 19, Vite, Tailwind).
- Storage: request bodies in Postgres (
bytea) behind aBodyStoreinterface (object-store backend can drop in later). Postgres 18 for nativeuuidv7()— time-ordered ids keep insert-heavy tables clustered.
All config is via environment variables in .env (copied from .env.example). The defaults are safe for local dev out of the box. Highlights:
| Var | Default | Purpose |
|---|---|---|
DSTREAM_SESSION_SECRET |
(set for prod, ≥32 bytes) | HMAC secret for session cookies. Generate: openssl rand -hex 32 |
DSTREAM_DEV_MODE |
true (in example) |
Logs magic-link tokens to stdout so you can sign in without SMTP. Must be false in production (server refuses to boot dev-mode on a non-localhost URL). |
DSTREAM_COOKIE_SECURE |
false (in example) |
false for local HTTP; set true behind TLS (server refuses to boot insecure on a non-localhost URL). |
DSTREAM_ALLOW_PRIVATE_DESTINATIONS |
false |
Keep false: outbound delivery blocks loopback/private/metadata IPs (SSRF guard). Only enable on trusted self-host that delivers to private ranges. |
DSTREAM_INGEST_RATE_LIMIT_RPS |
100 |
Per-source ingest rate limit (0 disables). |
DSTREAM_WORKER_CONCURRENCY |
50 |
Delivery worker pool size (goroutines per worker process). |
DSTREAM_WORKER_PER_ORG_MAX_INFLIGHT |
0 (off) |
Max concurrent in-flight deliveries per org, fleet-wide. Set >0 (e.g. 20) in multi-tenant deployments so one org can't monopolize the worker pool. |
DSTREAM_MAGIC_LINK_TTL |
15m |
Sign-in link validity. |
DSTREAM_DB_URL, DSTREAM_REDIS_ADDR |
local defaults | Overridden automatically inside Docker to the in-network services. |
For production: set a real DSTREAM_SESSION_SECRET, DSTREAM_DEV_MODE=false, DSTREAM_COOKIE_SECURE=true, and a non-localhost DSTREAM_PUBLIC_BASE_URL (served over TLS).
Delivery scales horizontally — run more worker processes. They all drain the
same Redis fair queue, and dequeue is a single atomic Lua script, so each
task is processed by exactly one worker; no double-processing, no locks, no
coordination to configure.
# run 3 worker replicas of the same image
docker compose -f deploy/docker/docker-compose.yml up -d --scale worker=3- Total throughput =
replicas × DSTREAM_WORKER_CONCURRENCY(e.g. 3 × 50 = 150 concurrent deliveries). - No leader election needed. Each worker runs the scheduler + recoverer loops; they're idempotent (atomic Lua), so running them on every replica is safe.
- Per-org fairness and the per-org cap are fleet-wide. The round-robin ring and the
DSTREAM_WORKER_PER_ORG_MAX_INFLIGHTcounter live in Redis, so they're enforced across all replicas combined — size the cap against the total pool (replicas × concurrency), not per node. - At-least-once across restarts. If a worker crashes mid-delivery, its in-flight events are re-delivered by the recoverer once their lease expires; destinations should dedupe on the
Dstream-Event-Idheader. Shut down withSIGTERM(docker compose stop) for a graceful drain. - Kubernetes / other orchestrators: same idea — scale the
workerDeployment's replica count. All replicas share one Redis + Postgres; nothing is pinned to a node.
Secure by default:
- SSRF-guarded delivery — the worker refuses to POST to loopback/private/link-local (cloud-metadata) addresses; checked at dial time to defeat DNS rebinding.
- Session revocation — signed cookies carry an epoch; logout invalidates all of a user's sessions.
- CSRF double-submit on the dashboard; API keys are exempt by construction.
- Rate limits on ingest and magic-link issuance; per-destination rate + in-flight caps on delivery.
- HMAC signature verification of inbound webhooks (per-source config; recorded on each request).
- Sensitive inbound headers (
Authorization,Cookie) are stripped before forwarding to destinations.
| # | Phase | Status |
|---|---|---|
| 1 | Core inbound gateway — ingest → dedup → deliver → retry → dashboard | ✅ shipped + hardened |
| 2 | Outbound webhooks (publish + subscriber fan-out with signing) | planned |
| 3 | Transformations + filters (per-connection JS via goja) |
planned |
| 4 | Record / replay + fixture library | planned |
| 5 | Visual workflow builder | planned |
| 6 | Multi-tenant hardening — full RBAC, SSO, audit, billing hooks | planned |
| 7 | Self-host packaging — Helm, single-binary release | planned |
cmd/dstream/ CLI entry — server | worker | cli | migrate | admin
internal/
ingest/ HTTP receiver, dedup, signature verify, enqueue, body store
deliver/ HTTP delivery, retry policy, rate limit, SSRF guard, reaper
dqueue/ Redis per-org fair-scheduling delivery queue (Lua + client)
api/ REST API (sources, destinations, connections, events, CLI tunnel)
admin/ /admin/* routes (overview, orgs, queue stats)
auth/ API keys, signed sessions, magic links, CSRF, middleware
store/ sqlc-generated Postgres access
config/ logging/ Viper config, slog
db/
migrations/ Atlas migrations (embedded in the binary, auto-applied)
queries/ sqlc query inputs schema/ reference schema
deploy/docker/ Dockerfile, web.Dockerfile, docker-compose.yml
web/ Tanstack Start dashboard
PLAN.md live design doc — single source of truth
| Symptom | Fix |
|---|---|
up fails on a stale Postgres volume |
PG18 won't start on older data. docker compose -f deploy/docker/docker-compose.yml down -v then up again (dev data is disposable). |
| Can't sign in / no magic link | Ensure DSTREAM_DEV_MODE=true in .env, then read the link from logs -f server. |
| Port already in use | Free host ports 3000 (web), 8080 (server), 6379 (redis), 5433 (postgres) — a local Redis on 6379 is the usual clash. |
/admin/queues returns 403 |
Promote yourself: docker compose -f deploy/docker/docker-compose.yml exec server dstream admin promote [email protected], then re-login. |
| Delivery to a localhost destination fails | The SSRF guard blocks private IPs. Use the CLI tunnel for local forwarding, or set DSTREAM_ALLOW_PRIVATE_DESTINATIONS=true for trusted local testing. |
TBD before first public release. Current intent: AGPL-3.0, with a separate commercial license for hosted SaaS.