
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
- 10-category 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 (50+ functions) 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 10 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
See the architecture reference for the package structure and dependency graph.
License
MIT