
Sparrow
Self-hosted webhook delivery platform with async fan-out, retries, health tracking, and observability. Built for teams that need reliable outbound webhooks without depending on a third-party service.

Features
Delivery & Reliability
- Event-driven fan-out -- push one event, deliver to all matching subscriptions
- At-least-once delivery -- configurable retries with exponential backoff
- Idempotent event ingestion -- optional idempotency keys to prevent duplicate processing
- Per-webhook rate limiting -- leaky bucket algorithm with HTTP 429 Retry-After parsing
- Error classification -- DNS, TLS, timeout, connection refused, rate limited, and more -- each with retryability flags
- Bulk operations -- deterministic snapshot-based batch re-push and retry (up to 10K items)
Security
- Dual webhook signing -- every delivery is signed with both HMAC-SHA256 and Ed25519 (Standard Webhooks format)
- Envelope encryption at rest -- AES-256-GCM with per-record data encryption keys for webhook secrets and sensitive headers
- SSRF protection -- blocks private/loopback/metadata IPs, validates redirect targets
- Optional API key auth -- constant-time comparison, HTTP + gRPC support
Developer Experience
- Payload transformation -- Go templates per subscription with built-in helpers to reshape payloads for different consumers
- Soft schema validation -- warnings not errors; events are always accepted and stored
- Dual-protocol API -- gRPC on
:50051 and Connect-RPC (HTTP/JSON) on :8080
- Web dashboard -- embedded SvelteKit UI for managing webhooks, events, deliveries, and health
- Health tracking -- per-webhook state machine (healthy/degraded/unhealthy) with rolling summaries
Operations
- PostgreSQL only -- no Redis, no message broker, no external dependencies beyond one database
- OpenTelemetry -- traces, metrics, and structured logs with job-level trace propagation
- Helm chart -- security-hardened Kubernetes deployment (NetworkPolicy, read-only rootfs, non-root, seccomp)
- One-click deploy -- Railway, Docker Compose, or any container platform
Quick Start
Download deploy/docker-compose.yml and start it:
curl -O https://raw.githubusercontent.com/sarathsp06/sparrow/main/deploy/docker-compose.yml
SPARROW_ENCRYPTION_KEY=$(openssl rand -hex 32) docker compose up -d
Open http://localhost:8080 for the web UI.
Send your first event
# Register an event type
curl -X POST http://localhost:8080/webhook.EventService/RegisterEvent \
-H "Content-Type: application/json" \
-d '{"name": "order.created", "description": "New order", "active": true}'
# Register a webhook (subscription is created automatically)
curl -X POST http://localhost:8080/webhook.WebhookService/RegisterWebhook \
-H "Content-Type: application/json" \
-d '{"namespace": "default", "url": "https://httpbin.org/post", "events": ["order.created"], "active": true}'
# Push an event -- Sparrow fans out and delivers
curl -X POST http://localhost:8080/webhook.EventService/PushEvent \
-H "Content-Type: application/json" \
-d '{"namespace": "default", "event": "order.created", "payload": {"order_id": "ord_123", "amount": 99.99}}'
Check delivery status in the web UI at Deliveries, or query the API:
curl -X POST http://localhost:8080/webhook.DeliveryService/ListDeliveries \
-H "Content-Type: application/json" \
-d '{"namespace": "default", "limit": 5}'
Use Cases
- SaaS webhook notifications -- notify customer endpoints when resources change
- Internal event bus -- fan out domain events to downstream services over HTTP
- Reliability layer -- add retries, health tracking, and observability to existing webhook flows
- Development and testing -- inspect deliveries, replay failed events, test payload transforms
Architecture
PushEvent API
-> persist event in PostgreSQL
-> enqueue fan-out job (River)
-> match subscriptions, apply transforms, create deliveries
-> enqueue delivery jobs
-> HTTP POST with HMAC signature
-> retry on failure (server errors, timeouts, network errors)
-> track health per webhook
Events are persisted before delivery. The River job queue provides at-least-once delivery with configurable retries (default: 3 attempts, 60s backoff). Failures are classified into categories -- retryable (5xx, timeout, connection refused, network error, rate limited) and non-retryable (4xx, DNS, TLS) -- so you know why a delivery failed, not just that it failed. Every delivery is dual-signed with HMAC-SHA256 and Ed25519 using the Standard Webhooks format. Webhook secrets and sensitive headers are envelope-encrypted at rest using AES-256-GCM with per-record data encryption keys.
See the architecture reference for the full pipeline design, error classification, and health state machine.
Verifying Webhook Signatures
Every delivery includes three Standard Webhooks headers:
| Header |
Example |
webhook-id |
msg_abc123-def456 |
webhook-timestamp |
1716048000 (Unix seconds) |
webhook-signature |
v1,K7gNU3sdo+OL... or v1a,RjB2mN... |
The signed message is always: {webhook-id}.{webhook-timestamp}.{raw request body} -- the exact bytes of the JSON body, not re-serialized.
The webhook-signature prefix tells you which algorithm was used:
v1, -- HMAC-SHA256 (symmetric, requires the shared webhook secret)
v1a, -- Ed25519 (asymmetric, requires only the public key from the API)
Verifying HMAC-SHA256 (v1,)
import hmac, hashlib, base64
def verify_hmac(body: bytes, secret: str, headers: dict) -> bool:
msg_id = headers["webhook-id"]
timestamp = headers["webhook-timestamp"]
signature = headers["webhook-signature"] # "v1,<base64>"
# Extract the base64 portion after "v1,"
expected_b64 = signature.removeprefix("v1,")
# Reconstruct the signed message
message = f"{msg_id}.{timestamp}.{body.decode()}"
# Compute HMAC-SHA256
computed = hmac.new(secret.encode(), message.encode(), hashlib.sha256).digest()
computed_b64 = base64.b64encode(computed).decode()
return hmac.compare_digest(computed_b64, expected_b64)
func VerifyHMAC(body []byte, secret, msgID, timestamp, signatureHeader string) bool {
// Extract "v1,<base64>" -> "<base64>"
b64Sig, _ := strings.CutPrefix(signatureHeader, "v1,")
message := msgID + "." + timestamp + "." + string(body)
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(message))
expected := base64.StdEncoding.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(expected), []byte(b64Sig))
}
Verifying Ed25519 (v1a,)
The public key is returned in the signing_public_key field when you register or retrieve a webhook.
import base64
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
def verify_ed25519(body: bytes, public_key_b64: str, headers: dict) -> bool:
msg_id = headers["webhook-id"]
timestamp = headers["webhook-timestamp"]
signature = headers["webhook-signature"] # "v1a,<base64>"
sig_bytes = base64.b64decode(signature.removeprefix("v1a,"))
pub_key = Ed25519PublicKey.from_public_bytes(base64.b64decode(public_key_b64))
message = f"{msg_id}.{timestamp}.{body.decode()}".encode()
try:
pub_key.verify(sig_bytes, message)
return True
except Exception:
return False
func VerifyEd25519(body []byte, publicKeyB64, msgID, timestamp, signatureHeader string) bool {
b64Sig, _ := strings.CutPrefix(signatureHeader, "v1a,")
sig, _ := base64.StdEncoding.DecodeString(b64Sig)
pubKey, _ := base64.StdEncoding.DecodeString(publicKeyB64)
message := []byte(msgID + "." + timestamp + "." + string(body))
return ed25519.Verify(ed25519.PublicKey(pubKey), message, sig)
}
Replay Protection
Always validate the webhook-timestamp header to prevent replay attacks. Reject deliveries where the timestamp is more than 5 minutes from your server's current time.
Configuration
All configuration is via environment variables:
| Variable |
Required |
Default |
Description |
DATABASE_URL |
Yes |
-- |
PostgreSQL connection string |
SPARROW_SERVE_UI |
No |
false |
Serve the embedded web dashboard |
SPARROW_API_KEY |
No |
-- |
Require this key in X-API-Key header |
SPARROW_ENCRYPTION_KEY |
Yes |
-- |
64-char hex key for envelope encryption of secrets. Generate with openssl rand -hex 32 |
OTEL_EXPORTER_OTLP_ENDPOINT |
No |
-- |
OTLP endpoint for traces/metrics/logs |
See the configuration reference for the full list.
Deployment
Railway

Deploy Sparrow + PostgreSQL on Railway with zero infrastructure. See the Railway deployment guide for setup steps.
Docker
Pre-built multi-arch images (linux/amd64, linux/arm64) are published on every release:
docker pull ghcr.io/sarathsp06/sparrow:latest
See Docker Compose deployment guide for details.
Kubernetes
A Helm chart is included at charts/sparrow/:
helm install sparrow charts/sparrow/ \
--set secrets.databaseURL="postgres://user:pass@your-db:5432/sparrow?sslmode=require"
See Kubernetes deployment guide for all chart values and examples.
Documentation
sarathsp06.github.io/sparrow
API reference docs are generated from webhook.proto using proto2astro.
In-repo docs: Configuration | Architecture | Kubernetes | webhook.proto
Contributing
Contributions are welcome. Please open an issue to discuss larger changes before submitting a PR.
git clone https://github.com/sarathsp06/sparrow.git
cd sparrow
make build-with-ui # build server + embedded UI
make test # run tests
make lint # run linters
Release Process
Releases are automated by CI (GoReleaser runs in GitHub Actions).
# 1) create a semantic version tag
git tag vX.Y.Z
# 2) push main + tags
git push origin main --tags
Notes:
- Use Conventional Commit prefixes (
feat:, fix:, docs:, etc.) for clean autogenerated release notes.
- If a tag already exists remotely, create the next version tag and push that tag instead.
See the architecture reference for the package structure and dependency graph.
See CONTRIBUTING.md for the full contributor workflow.
License
MIT