You are answering questions about Sparrow, a self-hosted webhook delivery platform. Important product facts: - Sparrow is one Go service plus PostgreSQL. No Redis or external queue; River runs in PostgreSQL. - The main API is REST/OpenAPI on :8080. - The embedded admin UI is intended for trusted/private networks. - The consumer portal pattern (`/portal` plus restricted proxying of `/portal/api/*`, `/_app/*`, and `/favicon.png`) is the safe public-facing surface. Critical security rules: - Do NOT recommend exposing Sparrow’s root UI or full `/v1/*` API directly to the public internet. - For external end users, recommend the consumer portal pattern: - mint portal tokens server-side - keep `SPARROW_API_KEY` server-side only - forward only `/portal`, `/portal/api/*`, `/_app/*`, and `/favicon.png` - never inject `X-API-Key` on proxied portal routes - Do NOT claim consumers are a hard security boundary for admin/API-key access. - Do NOT claim event payloads are encrypted in PostgreSQL; webhook secrets and sensitive headers are encrypted, but event payloads and delivery bodies remain plaintext in the database. - Sparrow now uses keyring-based encryption config: - `SPARROW_ENCRYPTION_KEYS` - `SPARROW_ENCRYPTION_PRIMARY_KEY_ID` - Portal tokens are consumer-scoped access tokens (`sparrow_tk_...`) minted by `POST /v1/tokens` with a `consumer` (the response includes `portal_path`): revocable via `DELETE /v1/tokens/{id}`, 7-day default TTL (use a short `ttl_seconds` for embedding), and `external_id` (your id for who the token is for) returns the still-valid token for that consumer and id instead of minting a new one. There is no separate portal-token endpoint. - If the use case involves PHI/PII/compliance, explicitly mention retention, DB/storage-layer encryption, and response-body capture tradeoffs. Answering style: - Prefer concrete deployment or integration steps over marketing summary. - When the user asks “how do I expose/embed/integrate Sparrow in my app?”, route immediately to the portal embedding guidance. - When the user asks “how do I secure Sparrow?”, distinguish: 1. internal admin access behind VPN/auth proxy 2. external consumer self-service via portal - When giving examples, use Sparrow’s actual routes and env vars. Use these docs in this order: 1. Read `llms-small.txt` first for most answers. 2. Read `llms-full.txt` only when the small docs do not contain enough detail. 3. Prefer exact operational guidance over general architectural summary. This file is the full developer documentation for Sparrow. Use it when the abridged file lacks enough detail. # Access: Tokens and Invites > Give people and machines their own revocable credentials instead of sharing the master key. Sparrow’s master key (`SPARROW_API_KEY`) works like a root password: powerful, but sharing it means you can never revoke one holder without rotating it for everyone. **Access tokens** solve this — each person or CI job gets a named, individually revocable credential. **Invites** let you hand out tokens without pasting secrets into chat. ## First run [Section titled “First run”](#first-run) When `SPARROW_API_KEY` is set (always the case with the production Docker Compose), the embedded UI shows a **Sign in to Sparrow** prompt on the first `401`. You can paste the master key there — the UI exchanges it for a browser token behind the scenes, so the master key is never stored in the browser. An access token or invite link also works. ## Invite a teammate [Section titled “Invite a teammate”](#invite-a-teammate) ### From the web UI [Section titled “From the web UI”](#from-the-web-ui) 1. Open the **Access** page in the sidebar. 2. Click **Invite**. 3. Enter a name (e.g. “alice”), choose **Full access** or a single consumer’s portal, pick a link expiry (1 hour, 24 hours, or 7 days), and optionally set how long their access lasts. 4. Copy the link and send it to them. When they open the link, the UI redeems the invite and signs them in automatically. The invite works once; expired, cancelled, or already-used invites show a clear message. ### From the CLI [Section titled “From the CLI”](#from-the-cli) ```bash sparrow invite alice sparrow invite alice --ttl 15m --ui-url https://sparrow.example.com sparrow invite "acme support" --consumer acme --ttl 7d ``` The printed link defaults to the server URL. Pass `--ui-url` when the UI is [hosted separately](/sparrow/deployment/separate-ui/). ### From the API [Section titled “From the API”](#from-the-api) ```bash curl -X POST http://localhost:8080/v1/invites \ -H "X-API-Key: $SPARROW_API_KEY" \ -H "Content-Type: application/json" \ -d '{"name": "alice", "ttl_seconds": 900}' ``` The response includes a `path` (`/#invite=` for the console, `/portal#invite=` for a consumer invite). Prepend the UI’s base URL. ## Remove access [Section titled “Remove access”](#remove-access) Revoke a token from the **Access** page (click **Revoke**), the CLI, or the API: ```bash sparrow tokens revoke ``` The token stops working immediately on the revoking instance. Other instances stop accepting it within 30 seconds (the cache TTL). ## CI and machine tokens [Section titled “CI and machine tokens”](#ci-and-machine-tokens) Create a token directly — no invite link needed: ```bash sparrow tokens create --name ci-deploy sparrow tokens create --name acme-sync --consumer acme --ttl 30d ``` The secret is printed once. Store it in your CI secret store and use it as `X-API-Key` or `Authorization: Bearer`. Tenant-wide tokens (no `--consumer`) expire after the server’s default — 90 days unless `SPARROW_TOKEN_DEFAULT_TTL` changes it — unless `--ttl` is given. For a CI credential you rotate by hand, `--ttl never` creates one that does not expire; revoke it when it is retired. Consumer tokens default to 7 days, allow at most 30, and always expire. ## Consumer (portal) access [Section titled “Consumer (portal) access”](#consumer-portal-access) Two ways to give consumers access to the portal. Both produce a consumer-scoped access token: listed in `sparrow tokens list`, revocable on its own, and limited to that consumer’s portal. ### Invites (for named people) [Section titled “Invites (for named people)”](#invites-for-named-people) ```bash sparrow invite "acme support" --consumer acme ``` The link works once and opens the consumer’s portal with a token named after the invitee. ### Portal links (for embedding) [Section titled “Portal links (for embedding)”](#portal-links-for-embedding) ```bash curl -X POST http://localhost:8080/v1/tokens \ -H "X-API-Key: $SPARROW_API_KEY" -H "Content-Type: application/json" \ -d '{"name": "portal", "consumer": "acme", "ttl_seconds": 3600, "external_id": "user-42"}' ``` Every consumer token comes with a ready-made `portal_path`. Your backend mints one for a user who is already signed in to your product and hands them that link; revoke it early with `DELETE /v1/tokens/{id}` (for example on logout). Use a short `ttl_seconds` for embedding. `external_id` (consumer tokens only) is your id for who the token is for, for example your user id. There is at most one active token per consumer and `external_id`, so the call is safe to repeat on every page view: while that token is valid, the same token and link come back (`"reused": true`; `name` and `ttl_seconds` are ignored); once it has expired or been revoked, a new one is created. The secret of such a token is kept envelope-encrypted with `SPARROW_ENCRYPTION_KEYS` so it can be returned again; tokens without an `external_id` store only a hash. The `external_id` itself is not a secret. Expired and revoked tokens stay listed for 7 days, then a daily job deletes them, so frequently minted portal links do not grow the tokens table without bound. ## What happens on master-key rotation [Section titled “What happens on master-key rotation”](#what-happens-on-master-key-rotation) Rotating `SPARROW_API_KEY` invalidates the old master key but **does not invalidate existing tokens or pending invites**. Tokens are verified by their stored hash, not by a relationship to the master key. ## Lifetimes [Section titled “Lifetimes”](#lifetimes) | Credential | Default | Maximum | | -------------------- | ------------------------------------- | ------------------------------------------------------- | | Tenant-wide token | `SPARROW_TOKEN_DEFAULT_TTL` (90 days) | No limit, or never with `--ttl never` / `never_expires` | | Consumer token | 7 days | 30 days | | Invite (link expiry) | 24 hours | 7 days | Durations accept Go syntax plus a `d` suffix: `90d`, `12h`, `15m`. Set `SPARROW_TOKEN_DEFAULT_TTL=0` to restore the previous behaviour, where tenant-wide tokens never expire unless a TTL is given. Browser sign-ins (a pasted master key or an invite) also get the default lifetime, so people sign in again after it lapses. ## API endpoints [Section titled “API endpoints”](#api-endpoints) All under `/v1` (require the master key or a tenant-wide token): | Endpoint | Method | Description | | ------------------------- | ------ | ------------------------------------------------ | | `/v1/whoami` | GET | Show which credential this request used | | `/v1/tokens` | POST | Create a token (secret returned once) | | `/v1/tokens` | GET | List tokens (`?consumer=`, `?include_inactive=`) | | `/v1/tokens/{token_id}` | DELETE | Revoke a token (idempotent) | | `/v1/invites` | POST | Create an invite | | `/v1/invites` | GET | List invites (`?include_inactive=`) | | `/v1/invites/{invite_id}` | DELETE | Cancel a pending invite (409 if not pending) | Outside `/v1`, no auth required (the invite is the credential): | Endpoint | Method | Description | | ---------------- | ------ | ----------------------------------------------- | | `/invite/redeem` | POST | Redeem an invite (`{"invite": "..."}` -> token) | ## Security notes [Section titled “Security notes”](#security-notes) * **Possession-based identity.** Tokens prove you have the secret, not who you are. There are no accounts, passwords, or MFA. A leaked token is valid until revoked. * **SHA-256 hashing.** Only the hash is stored; the plaintext secret is shown once at creation. Prefixes (`sparrow_tk_`, `sparrow_inv_`) make leaked secrets easy to spot in logs and secret scanners. * **503, not 401, on DB outage.** If the token store is unreachable, the server returns `503 Service Unavailable` with `Retry-After`, not `401`. Browsers keep their credential and retry. The master key still works without the database. * **30-second revocation window.** Successful token lookups are cached for 30 seconds. Revocation is immediate on the revoking instance; other instances honour it within 30 seconds. * **XSS risk.** The browser token lives in `localStorage`. An XSS attack on the Sparrow UI domain could exfiltrate it. Set a tight CSP and keep the UI on a dedicated origin. * **No inbound rate limiting.** Brute-forcing token secrets is bounded by SHA-256 cost and network round-trip, not by explicit throttling. Rate-limit at the proxy if the API is internet-facing. # Docker Compose (Local) > Try Sparrow locally with Docker Compose -- no clone, no secrets, no .env. The fastest way to try Sparrow on your own machine. No clone, no secrets, no `.env` needed. > \[!NOTE] This Compose file is for **local evaluation**, not production. The API is open, the encryption key is a well-known all-zeros value, and the port is bound to `127.0.0.1` only. For a real deployment, see [Production Deployment](/sparrow/deployment/production/). ## Quick Start [Section titled “Quick Start”](#quick-start) ```bash curl -O https://raw.githubusercontent.com/sarathsp06/sparrow/main/deploy/docker-compose.yml docker compose up -d ``` * **Web UI:** * **REST API:** * **API docs:** SSRF protection is relaxed so webhooks can target services on your machine. Use `http://host.docker.internal:` to reach the host from inside Docker (works on macOS, Windows, and Linux with the Compose file’s `extra_hosts` mapping). ### Trying authentication locally [Section titled “Trying authentication locally”](#trying-authentication-locally) Uncomment `SPARROW_API_KEY` in the Compose file and restart: ```bash docker compose up -d ``` The UI will show a **Sign in to Sparrow** prompt. Paste the key from the Compose file (`local-test-key`) to sign in. API calls now need `-H "X-API-Key: local-test-key"`. You can also try [access tokens and invites](/sparrow/deployment/access/). To stop: ```bash docker compose down # stop containers docker compose down -v # stop and delete data ``` ## Docker Image [Section titled “Docker Image”](#docker-image) Pre-built multi-arch images (linux/amd64, linux/arm64) are published to GitHub Container Registry on every release: Latest Docker release: [`ghcr.io/sarathsp06/sparrow:latest`](https://github.com/sarathsp06/sparrow/pkgs/container/sparrow?tag=latest). ```bash docker pull ghcr.io/sarathsp06/sparrow:latest ``` You can also pin to a specific version: ```bash docker pull ghcr.io/sarathsp06/sparrow:0.5.6 ``` ## Development (Build from Source) [Section titled “Development (Build from Source)”](#development-build-from-source) The repo contains a `docker-compose.dev.yml` that builds from source. This is useful for development: ```bash git clone https://github.com/sarathsp06/sparrow.git cd sparrow docker compose -f docker-compose.dev.yml up -d ``` Or build without Docker: ```bash make build-with-ui export DATABASE_URL=postgres://user:pass@localhost:5432/sparrow?sslmode=disable make migrate SPARROW_SERVE_UI=true ./build/server-* ``` ## Observability [Section titled “Observability”](#observability) Sparrow exports traces, metrics, and logs via OpenTelemetry (OTLP). Set `OTEL_EXPORTER_OTLP_ENDPOINT` to point to your collector (set `OTEL_EXPORTER_OTLP_PROTOCOL=grpc` and port `4317` for OTLP/gRPC; see [Configuration](/sparrow/getting-started/configuration/#observability)): ```bash OTEL_EXPORTER_OTLP_ENDPOINT=http://your-otel-collector:4318 ``` # Production Deployment > Deploy Sparrow on Kubernetes or any container platform with proper secrets, hardening, and network exposure. Sparrow is a single Go binary backed by PostgreSQL. It runs on any container platform — this page uses Kubernetes as the worked example, but the same configuration applies to ECS, Cloud Run, Fly.io, or a plain Docker host behind a reverse proxy. > The [Docker Compose (local)](/sparrow/deployment/docker-compose/) page covers a zero-config setup for trying Sparrow on your own machine. Everything below assumes you are deploying for real use. ## Required configuration [Section titled “Required configuration”](#required-configuration) Set these environment variables on the Sparrow container. Keep them in a secret manager or Kubernetes Secret — never bake them into images or check them into source control. | Variable | How to generate | Notes | | ----------------------------------- | ------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------- | | `ENVIRONMENT` | `production` | The server refuses to start without `SPARROW_API_KEY` and blocks cross-origin browser requests by default. | | `SPARROW_API_KEY` | `openssl rand -hex 32` | Master key for `/v1` and the UI sign-in prompt. Must be at least 32 characters in production (the server refuses to start otherwise). | | `SPARROW_ENCRYPTION_KEYS` | `main=$(openssl rand -hex 32)` | Keyring for envelope encryption; each key is 32 random bytes as 64 hex chars. See [key rotation](/sparrow/getting-started/configuration/#key-management). | | `SPARROW_ENCRYPTION_PRIMARY_KEY_ID` | `main` | Which key ID in the keyring is used for new encryption. | | `DATABASE_URL` | — | PostgreSQL connection string. Use `sslmode=require` or `sslmode=verify-full` when the database is not on localhost. | | `SPARROW_SERVE_UI` | `true` or `false` | Serve the embedded web dashboard. | Other variables (`SPARROW_ALLOWED_NETWORKS`, `CORS_ALLOWED_ORIGINS`, `SPARROW_EVENT_RETENTION_DAYS`, `SPARROW_AI_API_KEY`, `OTEL_EXPORTER_OTLP_ENDPOINT`, etc.) are documented in [Configuration](/sparrow/getting-started/configuration/). ## Kubernetes manifests [Section titled “Kubernetes manifests”](#kubernetes-manifests) ### Secret [Section titled “Secret”](#secret) ```yaml apiVersion: v1 kind: Secret metadata: name: sparrow type: Opaque stringData: ENVIRONMENT: production SPARROW_API_KEY: "" SPARROW_ENCRYPTION_KEYS: "main=" SPARROW_ENCRYPTION_PRIMARY_KEY_ID: main DATABASE_URL: "postgres://sparrow:@db.internal:5432/sparrow?sslmode=require" SPARROW_SERVE_UI: "true" ``` Replace the placeholder values with real secrets. In a managed cluster, use an external secrets operator (AWS Secrets Manager, Vault, etc.) instead of inline `stringData`. ### Deployment [Section titled “Deployment”](#deployment) ```yaml apiVersion: apps/v1 kind: Deployment metadata: name: sparrow spec: replicas: 2 # safe: migrations use Postgres advisory locks; River queue is Postgres-backed selector: matchLabels: app: sparrow template: metadata: labels: app: sparrow spec: containers: - name: sparrow image: ghcr.io/sarathsp06/sparrow:vX.Y.Z # pin a release tag, not latest ports: - containerPort: 8080 envFrom: - secretRef: name: sparrow securityContext: runAsNonRoot: true runAsUser: 65532 # distroless nonroot readOnlyRootFilesystem: true allowPrivilegeEscalation: false capabilities: drop: [ALL] seccompProfile: type: RuntimeDefault livenessProbe: httpGet: path: /health port: 8080 initialDelaySeconds: 5 periodSeconds: 15 readinessProbe: httpGet: path: /ready port: 8080 initialDelaySeconds: 3 periodSeconds: 10 resources: requests: cpu: 100m memory: 128Mi limits: cpu: "1" memory: 512Mi ``` ### Service [Section titled “Service”](#service) ```yaml apiVersion: v1 kind: Service metadata: name: sparrow spec: type: ClusterIP selector: app: sparrow ports: - port: 8080 targetPort: 8080 ``` ## Network exposure [Section titled “Network exposure”](#network-exposure) Sparrow is designed to run behind a VPN. Expose it through an **internal** Ingress or LoadBalancer on the VPN network, with TLS terminated at the ingress controller. Never use a public-facing LoadBalancer unless you also put an authenticating proxy in front (see [Securing Sparrow](/sparrow/deployment/security/)). ### Example Ingress (internal) [Section titled “Example Ingress (internal)”](#example-ingress-internal) ```yaml apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: sparrow annotations: # Cloud-specific: mark as internal-only # AWS: alb.ingress.kubernetes.io/scheme: internal # GCP: networking.gke.io/internal: "true" spec: rules: - host: sparrow.internal.example.com http: paths: - path: / pathType: Prefix backend: service: name: sparrow port: number: 8080 tls: - hosts: [sparrow.internal.example.com] secretName: sparrow-tls ``` ### NetworkPolicy [Section titled “NetworkPolicy”](#networkpolicy) Restrict inbound traffic to the ingress controller and deny sensitive egress destinations: ```yaml apiVersion: networking.k8s.io/v1 kind: NetworkPolicy metadata: name: sparrow spec: podSelector: matchLabels: app: sparrow policyTypes: [Ingress, Egress] ingress: - from: - namespaceSelector: matchLabels: kubernetes.io/metadata.name: ingress-nginx # your ingress controller's namespace ports: - port: 8080 egress: # Allow DNS - to: - namespaceSelector: {} ports: - port: 53 protocol: UDP - port: 53 protocol: TCP # Allow PostgreSQL - to: - ipBlock: cidr: 10.0.0.0/8 # adjust to your DB subnet ports: - port: 5432 # Webhook delivery: any destination and port (receivers often listen on # 8080, 8443, ...), except the cloud metadata endpoint. Sparrow blocks it # too; this is defence in depth. Tighten to your receivers' CIDRs/ports # if you know them. - to: - ipBlock: cidr: 0.0.0.0/0 except: - 169.254.169.254/32 ports: - protocol: TCP ``` Adjust the namespace label and database CIDR to match your cluster. Which internal webhook targets are reachable is controlled by `SPARROW_ALLOWED_NETWORKS` (below), so the policy does not need to repeat it. ## SSRF protection [Section titled “SSRF protection”](#ssrf-protection) Sparrow’s SSRF check runs at connect time in every mode — it is never skipped. By default, private, loopback, and link-local addresses are blocked as webhook targets. **Cloud metadata endpoints are always blocked**, even with `SPARROW_ALLOW_PRIVATE_NETWORKS=true`: `169.254.169.254`, `169.254.170.2`, `169.254.170.23`, `100.100.100.200`, `fd00:ec2::254` and `fd00:ec2::23`, including their IPv4-mapped, 6to4 and NAT64 forms. They hand out cloud credentials; list one in `SPARROW_ALLOWED_NETWORKS` only if you truly mean to deliver there. The default blocklist also covers `0.0.0.0/8`, local-use NAT64 (`64:ff9b:1::/48`), and 6to4 (`2002::/16`) or NAT64 (`64:ff9b::/96`) addresses that embed a restricted IPv4 address — public 6to4/NAT64 destinations still work. To deliver webhooks to internal services (the common case on a VPN), use `SPARROW_ALLOWED_NETWORKS`: ```bash # Allow webhook deliveries to your VPN subnet SPARROW_ALLOWED_NETWORKS=10.20.0.0/16,fd12::/48 ``` Only the listed CIDRs are opened; loopback, cloud metadata, and other private ranges stay blocked. Invalid entries fail startup. When an allowlist is set, `.internal` and `.local` hostnames are no longer blocked by name; their resolved addresses are checked instead. `SPARROW_ALLOW_PRIVATE_NETWORKS=true` opens **all** private IPs (except cloud metadata) and is meant for local development and testing, not production. A Kubernetes egress NetworkPolicy (above) is still good defence in depth. ## Access and authentication [Section titled “Access and authentication”](#access-and-authentication) The server never writes `SPARROW_API_KEY` into the UI. When `SPARROW_API_KEY` is set, the embedded dashboard shows a **Sign in to Sparrow** prompt on the first `401`. Operators can: * Paste the master key (exchanged for a named browser token, never stored). * Use an [access token or invite link](/sparrow/deployment/access/) so the master key is never shared. * Put an [authenticating proxy](/sparrow/deployment/security/) in front for SSO. Give each person and CI job their own access token instead of sharing `SPARROW_API_KEY`. Use invites for people (`sparrow invite alice`), direct token creation for machines (`sparrow tokens create --name ci-deploy`). ## Production checklist [Section titled “Production checklist”](#production-checklist) * [ ] `ENVIRONMENT=production` and `SPARROW_API_KEY` set (at least 32 characters; `openssl rand -hex 32`). * [ ] Encryption keyring (`SPARROW_ENCRYPTION_KEYS` + `SPARROW_ENCRYPTION_PRIMARY_KEY_ID`) generated with `openssl rand -hex 32` and stored in a secret manager. Back it up — lose it and encrypted webhook secrets are unrecoverable. * [ ] `DATABASE_URL` points to a managed Postgres with `sslmode=require` or stronger. * [ ] Image tag pinned to a release (not `latest`). * [ ] Container runs as non-root (UID 65532), read-only filesystem, no privilege escalation, all capabilities dropped. * [ ] Liveness (`/health`) and readiness (`/ready`) probes configured. * [ ] Exposed only through an internal ingress or VPN — never a public LoadBalancer. * [ ] TLS terminated at the ingress controller or reverse proxy. * [ ] `CORS_ALLOWED_ORIGINS` set if the UI is hosted on a different origin. * [ ] Use `SPARROW_ALLOWED_NETWORKS` (not `SPARROW_ALLOW_PRIVATE_NETWORKS`) for internal webhook targets. Cloud metadata endpoints are always blocked. * [ ] `SPARROW_EVENT_RETENTION_DAYS` set if payloads carry regulated data. * [ ] Each person and CI job uses their own [access token](/sparrow/deployment/access/), not the master key. Tokens expire after `SPARROW_TOKEN_DEFAULT_TTL` (90 days) unless created with `--ttl never`. * [ ] Revoke tokens when someone leaves or a machine credential is retired. ## Upgrade notes [Section titled “Upgrade notes”](#upgrade-notes) * **`SPARROW_API_KEY` minimum length.** With `ENVIRONMENT=production`, the server now requires `SPARROW_API_KEY` to be at least 32 characters. If your existing key is shorter, generate a new one with `openssl rand -hex 32` before upgrading. Outside production mode, a short key logs a warning but still works. * **Cloud metadata always blocked.** Cloud metadata endpoints (169.254.169.254, 169.254.170.2, etc.) are now blocked even when `SPARROW_ALLOW_PRIVATE_NETWORKS=true`. If you need to reach one deliberately, list it in `SPARROW_ALLOWED_NETWORKS`. * **Tenant-wide tokens expire by default.** New tenant-wide tokens, browser sign-ins and invite-created tokens now expire after `SPARROW_TOKEN_DEFAULT_TTL` (90 days) unless created with an explicit TTL or `never_expires` (`sparrow tokens create --ttl never`). Existing tokens keep their lifetime. Set `SPARROW_TOKEN_DEFAULT_TTL=0` to keep the old never-expiring default. * **Custom header validation.** Webhook headers are now validated on save. Invalid HTTP token names, CR/LF or control characters in values, values over 8 KiB, and reserved framing headers (`Host`, `Content-Length`, `Transfer-Encoding`, `Connection`, `Keep-Alive`, `Proxy-Connection`, `TE`, `Trailer`, `Upgrade`) are rejected with `400`. Existing webhooks keep delivering: a reserved framing header stored before this check is skipped at delivery (it never had an effect), and the webhook only needs fixing the next time its headers are updated. # Securing Sparrow > Sparrow's security model, the trust assumptions of the embedded dashboard, and how to add SSO (username/password, Microsoft Entra, Google) with an identity-aware proxy. Sparrow’s built-in authentication is deliberately minimal: one shared secret (`SPARROW_API_KEY`) checked on every API request — optional in development, required when `ENVIRONMENT=production` (the server refuses to start without it). Everything beyond that — per-user logins, SSO, MFA, audit of *who* did what — is a deployment concern, solved by putting an identity-aware proxy in front of Sparrow rather than by building an identity provider into it. This page covers what Sparrow does natively, what it assumes about your network, and the recommended way to add real user authentication. For the implementation details — SSRF protection, HTTP hardening headers, secret masking, tenant isolation — see the [Security Model](/sparrow/reference/security/) reference page. ## What Sparrow provides natively [Section titled “What Sparrow provides natively”](#what-sparrow-provides-natively) | Control | Mechanism | | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | API authentication | `SPARROW_API_KEY` (master key) or per-person [access tokens](/sparrow/deployment/access/) → `X-API-Key` or `Authorization: Bearer` header; master key mandatory in production | | Secrets at rest | Envelope encryption (AES-256-GCM, per-record DEK) keyed by the `SPARROW_ENCRYPTION_KEYS` keyring | | Outbound signing | Standard Webhooks signatures (`v1` HMAC-SHA256, `v1a` Ed25519) on every delivery | | SSRF protection | Private/loopback/link-local/metadata IPs blocked at connect time in every mode; cloud metadata endpoints always blocked (even with `SPARROW_ALLOW_PRIVATE_NETWORKS=true`); `SPARROW_ALLOWED_NETWORKS` for targeted internal access; redirects re-validated | | Custom header validation | Header names must be valid HTTP tokens; values reject CR/LF/control chars and are capped at 8 KiB; reserved framing headers (`Host`, `Content-Length`, `Transfer-Encoding`, `Connection`, `Keep-Alive`, `Proxy-Connection`, `TE`, `Trailer`, `Upgrade`) are refused; Sparrow’s own signature and ID headers always win over custom headers | | Portal path validation | The portal gateway rejects paths containing `.`, `..`, empty segments, backslashes, or control characters (`403`) | | Browser access | `CORS_ALLOWED_ORIGINS` allowlist; security headers on all responses | What it does **not** provide: * **User accounts.** [Access tokens](/sparrow/deployment/access/) give each person and machine their own credential, but identity is possession-based — there are no accounts, passwords, or MFA. * **Tenant isolation via consumers.** Consumers organize resources; they are *not* a security boundary. Any valid API key can read and write every consumer. * **Rate limiting on the API.** Put a reverse proxy in front if you need it. ## Data retention and compliance posture [Section titled “Data retention and compliance posture”](#data-retention-and-compliance-posture) Sparrow stores event payloads and delivery request bodies as **plaintext in PostgreSQL** — they must stay queryable and transformable. Secrets and signing keys are envelope-encrypted; payloads are not. If payloads carry regulated data (PHI, PII), plan for it: * **Encrypt at the storage layer.** Encryption-at-rest requirements (HIPAA-style) are satisfied below the application: encrypted disks/volumes, or the managed encryption RDS/Cloud SQL enable by default, plus TLS to PostgreSQL. * **Bound how long data lives.** Set `SPARROW_EVENT_RETENTION_DAYS` to purge events — and, via cascade, their deliveries — older than N days. Runs hourly in the background. Unset (the default), data is kept forever. * **Keep response capture off.** `capture_response_body` is off by default; leave it off if endpoints may echo sensitive data back. * **Self-hosting is the compliance boundary.** Payloads never leave your infrastructure — there is no vendor to sign a BAA with. ## Trust model of the embedded dashboard [Section titled “Trust model of the embedded dashboard”](#trust-model-of-the-embedded-dashboard) The embedded web UI (`SPARROW_SERVE_UI=true`) is designed for **trusted, private networks** — a team dashboard on a VPN or internal network, not a public-facing app: * The dashboard and its assets are served **without authentication** so the login-free UI works out of the box. * The dashboard needs a credential to call the API. When `SPARROW_API_KEY` is set, the UI shows a **Sign in to Sparrow** prompt on the first `401`. A pasted master key is exchanged for a browser token so the master key is never stored. Access tokens and invite links also work. The server never writes the API key into the served HTML. * A [separately hosted UI](/sparrow/deployment/separate-ui/) behaves the same way by default. An `apiKey` in its `config.js` skips the prompt, but anyone who can load the UI can read the key. * To give a teammate access, create an invite (`sparrow invite alice`) instead of pasting the key into chat. The invite link lives in the URL fragment, so it never reaches server or proxy logs. It works once, expires (default 24 hours, max 7 days), and creates a named token the invitee can use. The invite can be cancelled before use; the resulting token can be revoked at any time. See [Access: Tokens and Invites](/sparrow/deployment/access/) for the full guide. Consequences: * On a private/VPN network where everyone with network access is trusted: fine as-is. * On a shared or internet-facing network: **do not expose the port directly.** Put an authenticating proxy in front (next section) or disable the UI. ## The consumer portal [Section titled “The consumer portal”](#the-consumer-portal) The portal (`/portal`) uses a different trust model: each visitor carries a **portal token** — an expiring, revocable, consumer-scoped access token minted with the admin key (`POST /v1/tokens` with a `consumer`). Portal users never log in and need no accounts; the magic link *is* the credential, and it can only touch that one consumer’s webhooks, subscriptions, and deliveries (never event injection, other consumers, or admin routes). The `/portal` HTML is served **without** the admin API key, and its framing headers allow embedding in an iframe. See the [security reference](/sparrow/reference/security/#portal-tokens) for the exact scope. How that plays out per network topology: * **Sparrow on a VPN, consumers internal too** — works as-is. Hand teams portal links instead of dashboard access: they manage their own endpoints without ever holding the admin key. * **Sparrow on a VPN, consumers external** — expose only the portal slice through your reverse proxy and keep everything else private: ![Portal slice topology: the internet reaches a proxy (TLS and rate limiting) that forwards only /portal, /\_app/\*, and the single bearer-scoped /portal/api/\* prefix to Sparrow, denying everything else; a separate VPN path reaches Sparrow :8080 for the admin UI, full API, and token minting.](/sparrow/_astro/security-portal-slice-light.9FE-NZ3a.svg) ![Portal slice topology: the internet reaches a proxy (TLS and rate limiting) that forwards only /portal, /\_app/\*, and the single bearer-scoped /portal/api/\* prefix to Sparrow, denying everything else; a separate VPN path reaches Sparrow :8080 for the admin UI, full API, and token minting.](/sparrow/_astro/security-portal-slice-dark.DV59tHtS.svg) `SPARROW_API_KEY` **must** be set in this topology — without it the auth middleware is disabled and the exposed paths are open to everyone. The “proxy” can be a standalone reverse proxy **or your own product’s backend** forwarding those routes over the VPN — same origin as your app, so no CORS setup and no iframe required. Two constraints: paths must be preserved verbatim (`/portal`, `/portal/api`, `/_app` are root-absolute in the SPA — use a dedicated subdomain if they collide with your app’s routes), and the proxy must never inject the admin `X-API-Key` on these routes. * **Embedded in your own product** (the intended design) — your app is already public and already authenticates its users. Its backend calls the mint endpoint over the private network and renders the returned `/portal#token=...` path in an iframe (or links to it via the proxy above). Sparrow delegates identity entirely to your product; the portal slice above is the only thing that needs to be reachable from the user’s browser. What does **not** work: fetching `/portal` HTML server-side and inlining it into your own pages. The portal is a single-page app — the HTML is only a shell whose scripts and API calls (`/_app/*`, `/portal/api/*`) run from the **visitor’s browser** and must reach Sparrow through one of the routes above. Copying the HTML copies neither. If you want zero Sparrow UI in the browser, build your own screens on the consumer-scoped REST API instead: your backend holds the token, calls Sparrow over the VPN, and renders native components. A full worked example of the external topology — nginx and Express proxy configs, the mint flow, and the threat analysis — is in [Embedding the Consumer Portal](/sparrow/guides/portal-embedding/). Portal caveats: the token rides in the URL fragment, so anyone the link is forwarded to can use it until it expires (default 7 days, max 30 — use a short `ttl_seconds` for embedding) or you revoke it with `DELETE /v1/tokens/{id}`. For named individuals, `sparrow invite "acme support" --consumer acme` creates a one-time link that opens the portal and grants a named token of the same kind. See [Access: Tokens and Invites](/sparrow/deployment/access/#consumer-portal-access). ## Recommended: SSO via an identity-aware proxy [Section titled “Recommended: SSO via an identity-aware proxy”](#recommended-sso-via-an-identity-aware-proxy) For per-user login — username/password, Microsoft Entra ID, Google, or any OIDC/SAML provider — run an open-source auth overlay in front of Sparrow. The proxy authenticates humans, then injects the `X-API-Key` header on every authenticated request. Sparrow needs zero configuration changes beyond setting the key, and the browser never sees the key at all. ![Auth proxy topology: the browser logs in at an auth proxy (password, Microsoft Entra, or Google), which injects the X-API-Key header and forwards to Sparrow :8080, which is not directly reachable; CI and machine services reach Sparrow directly over an internal route using X-API-Key.](/sparrow/_astro/security-auth-proxy-light.Dj412F67.svg) ![Auth proxy topology: the browser logs in at an auth proxy (password, Microsoft Entra, or Google), which injects the X-API-Key header and forwards to Sparrow :8080, which is not directly reachable; CI and machine services reach Sparrow directly over an internal route using X-API-Key.](/sparrow/_astro/security-auth-proxy-dark.Dy3pZcLP.svg) Rules for this topology: 1. Sparrow’s port must be reachable **only** from the proxy (and trusted machine clients). If clients can bypass the proxy, the overlay is decoration. 2. Set `SPARROW_API_KEY`; give it only to the proxy config and machine clients. 3. Set `CORS_ALLOWED_ORIGINS` to the proxy’s public origin (or leave unset if the UI is served through the same origin). 4. **Never inject `X-API-Key` on `/portal` or `/portal/api/*` if portal users go through the same proxy.** A valid API key outranks portal token scoping, so blanket header injection silently escalates every portal visitor to admin. Exclude those paths from injection, or serve the portal on a separate route/vhost without the overlay. ### Option A — Authentik (username/password + Entra + Google in one tool) [Section titled “Option A — Authentik (username/password + Entra + Google in one tool)”](#option-a--authentik-usernamepassword--entra--google-in-one-tool) [Authentik](https://goauthentik.io) is a self-hosted identity provider with a built-in **Proxy Provider** mode, so it is both the IdP and the overlay: * Local user database (passwords, passkeys, MFA) **and** federated sources (Microsoft Entra ID, Google, generic OIDC/SAML) side by side. * Proxy Provider runs as an embedded outpost or standalone container; speaks forward-auth natively with Traefik, nginx, and Caddy. * In the Proxy Provider settings, add a custom header injection so every proxied request carries `X-API-Key: `. Deployment: two containers (server + worker) plus PostgreSQL — you already run PostgreSQL for Sparrow. Verify that the external OAuth/SAML source types you need are available in the open-source tier of your Authentik version. ### Option B — oauth2-proxy (single IdP, smallest footprint) [Section titled “Option B — oauth2-proxy (single IdP, smallest footprint)”](#option-b--oauth2-proxy-single-idp-smallest-footprint) If **all** users live in one IdP (an Entra tenant or a Google Workspace domain), [oauth2-proxy](https://github.com/oauth2-proxy/oauth2-proxy) is a single Go binary that does the whole job: ```text oauth2-proxy \ --provider=oidc \ --oidc-issuer-url=https://login.microsoftonline.com//v2.0 \ --upstream=http://sparrow:8080 \ --email-domain=yourcompany.com ``` Inject the API key upstream via its header configuration (e.g. an `injectRequestHeaders` entry in the alpha config, or terminate at nginx and add `proxy_set_header X-API-Key ...`). No local username/password support — that’s the tradeoff for the small footprint. ### Option C — Keycloak + oauth2-proxy (maximum boring) [Section titled “Option C — Keycloak + oauth2-proxy (maximum boring)”](#option-c--keycloak--oauth2-proxy-maximum-boring) [Keycloak](https://www.keycloak.org) if you want the battle-tested enterprise IdP: local users, identity brokering (Entra, Google, SAML), fine-grained roles — all free. Keycloak is only the IdP; pair it with oauth2-proxy (pointing at Keycloak as its OIDC issuer) as the actual overlay. Two moving parts instead of one, but every part is thoroughly documented and widely deployed. ### Not a fit [Section titled “Not a fit”](#not-a-fit) * **Authelia** — local users only; it [cannot consume external OIDC providers](https://www.authelia.com/configuration/identity-providers/openid-connect/provider/) (no Relying Party role), so no “login with Entra/Google”. * **Pomerium** — excellent identity-aware proxy, but delegates to a single upstream IdP and has no built-in username/password store. ## Hardening checklist [Section titled “Hardening checklist”](#hardening-checklist) Whether or not you add SSO: * [ ] Set `SPARROW_API_KEY` (generate: `openssl rand -hex 32`, at least 32 characters) — mandatory when `ENVIRONMENT=production`, and without it anyone who can reach the port owns the instance regardless of environment. * [ ] Configure `SPARROW_ENCRYPTION_KEYS` and `SPARROW_ENCRYPTION_PRIMARY_KEY_ID`, and store the key material in a secret manager, never in the database or repo. * [ ] Use TLS to PostgreSQL (`sslmode=require` or stronger) whenever the database is not on localhost. * [ ] Set `CORS_ALLOWED_ORIGINS` explicitly for any browser-based access. * [ ] Use `SPARROW_ALLOWED_NETWORKS` (comma-separated CIDRs) to reach internal webhook targets instead of `SPARROW_ALLOW_PRIVATE_NETWORKS=true`. Cloud metadata endpoints are always blocked regardless of either setting. * [ ] Set `SPARROW_EVENT_RETENTION_DAYS` if payloads carry regulated data or you have any retention policy — the default keeps events forever. * [ ] Terminate TLS and apply rate limiting at a reverse proxy; Sparrow serves plain HTTP. * [ ] Confirm the server never writes `SPARROW_API_KEY` into the UI (this is now the default — the server no longer supports key injection). * [ ] Give people and CI jobs their own [access tokens](/sparrow/deployment/access/) instead of sharing `SPARROW_API_KEY`. Use invites for people, direct token creation for machines. * [ ] Revoke tokens when someone leaves or a machine credential is retired (`sparrow tokens revoke ` or `DELETE /v1/tokens/{id}`). * [ ] Keep `/health`, `/ready`, `/docs`, and `/openapi.*` in mind: they are intentionally unauthenticated. Restrict at the proxy if the OpenAPI spec is sensitive in your environment. # Hosting the UI Separately > Serve the Sparrow dashboard from its own static host and point it at a Sparrow server on another origin. By default the Sparrow server serves the dashboard itself (`SPARROW_SERVE_UI=true`), on the same origin as the API. That needs no extra configuration and is what the Docker images and Compose files do. This page covers the other layout: the dashboard is a static site (nginx, a CDN, object storage) on one origin, e.g. `https://sparrow.example.com`, and the Sparrow server runs on another, e.g. `https://sparrow-api.example.com`. ## What changes when the UI is separate [Section titled “What changes when the UI is separate”](#what-changes-when-the-ui-is-separate) | Concern | Embedded UI (`SPARROW_SERVE_UI=true`) | Separately hosted UI | | ------------------------------- | --------------------------------------------------------------- | ----------------------------------------------------------- | | Where the UI sends API calls | Same origin | `apiUrl` in `/config.js`, or `PUBLIC_API_URL` at build time | | API key (`SPARROW_API_KEY`) | Sign-in prompt on first `401` (key never written into the page) | Sign-in prompt on first `401`, or set in `/config.js` | | CORS | Not needed | `CORS_ALLOWED_ORIGINS` must list the UI origin | | Security headers (CSP, framing) | Set by Sparrow | Set by your static host | ## 1. Build the UI [Section titled “1. Build the UI”](#1-build-the-ui) ```bash cd web npm ci npm run build # output: ../internal/ui/dist ``` Upload the contents of `internal/ui/dist/` to your static host. The same build works for any server: the API URL is set at deploy time in `config.js` (next step), so you don’t need to rebuild for each environment. If you’d rather bake the URL in at build time, set `PUBLIC_API_URL` for the build: ```bash PUBLIC_API_URL=https://sparrow-api.example.com npm run build ``` `PUBLIC_API_URL` is read only by `vite build`. Changing it later means rebuilding. An `apiUrl` in `config.js` overrides it. ## 2. Point the UI at the server: `config.js` [Section titled “2. Point the UI at the server: config.js”](#2-point-the-ui-at-the-server-configjs) The build ships a `config.js` next to `index.html`. It is loaded before the app starts. Edit it on the static host: ```js window.__SPARROW_CONFIG__ = window.__SPARROW_CONFIG__ || { apiUrl: "https://sparrow-api.example.com", // apiKey: "", // optional, see "Authentication" below }; ``` * `apiUrl`: the absolute URL of the Sparrow server. A path prefix is fine (`https://gw.example.com/sparrow`) if a reverse proxy mounts Sparrow there. Trailing slashes are ignored. * `apiKey`: optional. Leave it out and the UI asks for the key when it needs it. Serve `config.js` and `index.html` with `Cache-Control: no-cache` so edits take effect straight away. ## 3. Serve it as a single-page app [Section titled “3. Serve it as a single-page app”](#3-serve-it-as-a-single-page-app) Every unknown path must return `index.html`, because the UI routes on the client. The UI must be served at the root of its origin (`https://sparrow.example.com/`), not under a sub-path, since assets are referenced as `/_app/...` and `/config.js`. nginx example: ```nginx server { listen 443 ssl; server_name sparrow.example.com; root /srv/sparrow-ui; location /_app/immutable/ { add_header Cache-Control "public, max-age=31536000, immutable"; } location = /config.js { add_header Cache-Control "no-cache"; } location / { add_header Cache-Control "no-cache"; try_files $uri /index.html; } } ``` ## 4. Configure the server [Section titled “4. Configure the server”](#4-configure-the-server) ```bash # Exact origin of the UI: scheme + host + port, no path. CORS_ALLOWED_ORIGINS=https://sparrow.example.com SPARROW_API_KEY= ENVIRONMENT=production # Optional: turn off the server's own copy of the UI. SPARROW_SERVE_UI=false ``` * `CORS_ALLOWED_ORIGINS` is required. With `ENVIRONMENT=production` and no allowlist, the server rejects every cross-origin request. Without `ENVIRONMENT=production` and no allowlist, it accepts any origin, which is only meant for local development. List more origins separated by commas. A trailing slash is ignored. * The UI sends the key in the `X-API-Key` header, never as a cookie, so no credentialed CORS is involved. ## Authentication [Section titled “Authentication”](#authentication) When the server has `SPARROW_API_KEY` set, the UI needs a credential. Pick one of these options (1 and 3 combine well): 1. **Prompt (default, recommended).** Leave `apiKey` out of `config.js`. On the first `401` the UI shows a **Sign in to Sparrow** prompt. You can paste the master key or an access token. A pasted master key is exchanged for a browser token behind the scenes, so the master key is never stored in the browser. The sidebar shows “Signed in as \” with a **Sign out** button. If the stored credential stops working (revoked, expired, or key rotated), the prompt opens again with a clear message. 2. **`apiKey` in `config.js`.** Nobody has to type anything, but anyone who can load the UI can read the key (see [Security](/sparrow/deployment/security/)). `apiKey` may be the master key or a tenant-wide access token. Don’t use it if you expose the consumer portal from the same host, because portal visitors can download `config.js` too. 3. **One-time invite.** Run `sparrow invite alice --ui-url https://sparrow.example.com` (or `POST /v1/invites`) and send the printed link. Opening it redeems the invite and creates a named access token for that browser — the recipient never sees the master key. Each link works once, expires after its TTL (default 24 hours, max 7 days), and can be cancelled with `sparrow invites cancel`. Consumer invites (`--consumer acme`) open the portal instead of the console. See [Access: Tokens and Invites](/sparrow/deployment/access/) for the full guide. 4. **Authenticating proxy.** Put the UI and API behind a proxy that logs users in and adds `X-API-Key` itself (see [Security → auth proxy](/sparrow/deployment/security/)). Leave `apiKey` empty. ## Consumer portal [Section titled “Consumer portal”](#consumer-portal) The portal (`/portal`) works from a separately hosted UI. Its API calls go to `/portal/api/...` with the consumer’s bearer token, never the admin key. When you mint a link with `POST /v1/tokens` and a `consumer`, the response’s `portal_path` (`/portal#token=...`) is relative. Prepend the **UI’s** base URL (`https://sparrow.example.com/portal#token=...`), not the API’s. ## Local development [Section titled “Local development”](#local-development) `make run` (server on `:8080`) plus `make run-web` (vite on `:5173`) is also a split deployment. It works without configuration because the server allows any origin when `ENVIRONMENT` isn’t `production`, and the dev UI defaults to `http://localhost:8080`. If you run the server with `ENVIRONMENT=production`, also set `CORS_ALLOWED_ORIGINS=http://localhost:5173`. ## Troubleshooting [Section titled “Troubleshooting”](#troubleshooting) | Symptom | Likely cause | | ------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------- | | Browser console shows `blocked by CORS policy` | UI origin missing from `CORS_ALLOWED_ORIGINS` (check scheme and port), or the server is in production mode with no allowlist | | API calls go to the UI host and return HTML or 404 | No `apiUrl` in `config.js` and the build had no `PUBLIC_API_URL` | | **API key required** dialog keeps coming back | The key you entered doesn’t match the server’s `SPARROW_API_KEY` | | Reloading a deep link like `/webhooks/abc` returns 404 | Static host has no SPA fallback to `index.html` | | Blank page, `/_app/...` requests return 404 | UI served under a sub-path; serve it at the origin root | # Configuration > Environment variables and configuration options. All configuration is done via environment variables. No config files needed. ## Environment Variables [Section titled “Environment Variables”](#environment-variables) | Variable | Required | Default | Description | | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------- | --------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `DATABASE_URL` | Yes | `postgres://localhost/riverqueue?sslmode=disable` (dev-only fallback) | PostgreSQL connection string | | `SPARROW_SERVE_UI` | No | `false` | Serve the embedded web dashboard on the HTTP port | | `SPARROW_API_KEY` | No | — | Require this key in `X-API-Key` header for all API requests. Must be at least 32 characters when `ENVIRONMENT=production` (the server refuses to start otherwise); shorter keys log a warning in other environments. Generate with `openssl rand -hex 32`. | | `SPARROW_ENCRYPTION_KEYS` | Yes | — | Keyring entries as comma-separated `=<64-char-hex-key>` pairs where each value is a cryptographically random 32-byte (256-bit) key hex-encoded to 64 chars (`key-id` chars: `A-Z`, `a-z`, `0-9`, `_`, `-`) | | `SPARROW_ENCRYPTION_PRIMARY_KEY_ID` | Yes | — | Which configured key ID is primary for new encryption | | `SPARROW_HTTP_PORT` | No | `8080` | HTTP listen port for the REST/OpenAPI API (also serves the web UI) | | `SPARROW_TOKEN_DEFAULT_TTL` | No | `2160h` (90 days) | Lifetime of a tenant-wide access token created without `ttl_seconds` or `never_expires` (including browser sign-ins and invites). Go duration. `0` means such tokens never expire (the pre-upgrade behaviour). | | `SPARROW_MAX_CAPTURED_RESPONSE_BYTES` | No | `1048576` (1 MiB) | Stored response body limit for webhooks with `capture_response_body` enabled (others store 1 KiB). Minimum 1024. | | `SPARROW_ALLOWED_NETWORKS` | No | — | Comma-separated CIDRs or bare IPs (e.g. `10.20.0.0/16,fd12::/48`). Webhook deliveries may reach these networks in addition to public addresses; loopback, cloud metadata, and the rest of private space stay blocked. This is the recommended way to deliver to internal services on a VPN. Invalid entries fail startup. When an allowlist is set, `.internal` and `.local` hostnames are no longer blocked by name; their resolved addresses are checked instead. | | `SPARROW_ALLOW_PRIVATE_NETWORKS` | No | `false` | Allow all private IP addresses as webhook URLs (for local development and testing). Cloud metadata endpoints are still blocked; use `SPARROW_ALLOWED_NETWORKS` for targeted access in production. | | `ENVIRONMENT` | No | — | Deployment tag; any value is accepted. Set to `production` to block cross-origin requests by default (see `CORS_ALLOWED_ORIGINS`) and tag logs/OTel; any other value behaves as development. | | `OTEL_EXPORTER_OTLP_ENDPOINT` | No | — | OTLP collector URL for traces, metrics, and logs (e.g. `http://collector:4318`; `https://` for TLS). Export is off when unset. See [Observability](#observability). | | `OTEL_EXPORTER_OTLP_PROTOCOL` | No | `http/protobuf` | OTLP transport: `http/protobuf` or `grpc`. Any other value disables export and logs a warning at startup. | | `CORS_ALLOWED_ORIGINS` | No | — | Comma-separated list of exact browser origins allowed to call the API (e.g. `https://ui.example.com,https://admin.example.com`; trailing slashes are ignored). Required when the UI is [hosted separately](/sparrow/deployment/separate-ui/). When unset: with `ENVIRONMENT=production` every cross-origin request is rejected; otherwise every origin is allowed (local development only). | | `SPARROW_MAX_BODY_BYTES` | No | `5242880` (5 MiB) | Maximum request body size in bytes. Minimum 1 MiB; larger bodies get `413`. | | `SPARROW_EVENT_RETENTION_DAYS` | No | `0` (keep forever) | Purge events — and, via cascade, their deliveries — older than this many days. Runs hourly in the background. | | `SPARROW_AUTO_REGISTER_EVENTS` | No | `false` | When `true`, pushing an event whose type is not registered creates a schema-less event type instead of returning `404`. Meant for local development (`make run` turns it on); event types are never deleted, so in production a producer typo would become a permanent name. See [Event Type Versions](/sparrow/guides/event-type-versioning/#unregistered-event-names). | | `SPARROW_AI_PROVIDER` | No | `anthropic` | With no AI variables set at all, the editor still offers **Copy prompt for AI** (`POST /v1/subscriptions:draftTemplatePrompt`): the same grounded prompt, for pasting into any chat assistant. Configuring a provider upgrades that to in-place drafting with render verification. Chat API behind AI drafting. `anthropic` uses the Anthropic API (set `SPARROW_AI_API_KEY`). `openai` uses any OpenAI-compatible `/v1/chat/completions` server, local or hosted: Ollama, vLLM, LM Studio, llama.cpp, OpenRouter, OpenAI (set `SPARROW_AI_BASE_URL` and `SPARROW_AI_MODEL`; the key is optional for local servers). Drafts are short and render-verified with up to three repair rounds, so a light model is usually enough. | | `SPARROW_AI_API_KEY` | No | — | Provider API key. With `anthropic`, setting it enables drafting; with `openai` it is sent as a Bearer token when present. When drafting is enabled the subscription editor shows a **Draft with AI** panel and `POST /v1/subscriptions:draftTemplate` is enabled: describe the body the receiver should get and Sparrow drafts the `transform_template`, grounded in the event type’s JSON Schema and sample payload, the template helper catalog, and optionally a shipped recipe’s destination format or an example body you paste. Every draft is rendered against the sample payload (the same dry-run as the preview) and repaired until it renders. The request can also carry a sample payload to draft against, a description or example of what the receiver expects, and a documentation URL that Sparrow fetches under the same network policy as deliveries (private and cloud-metadata addresses refused unless allowed). Only the schema, that sample payload, and the request’s own text are sent to the model — never stored events, headers, or secrets. Unset disables the feature; `GET /v1/capabilities` tells clients which. | | `SPARROW_AI_MODEL` | `openai`: yes | `claude-haiku-4-5` for `anthropic` | Model used for drafting. Raise it if drafts regularly need more than a couple of repair rounds. For `openai` name the model the server serves, e.g. `llama3.2`, `qwen2.5-coder:7b`, `gpt-4o-mini`. | | `SPARROW_AI_BASE_URL` | `openai`: yes | — | API base URL. `openai`: the server’s OpenAI-compatible root, e.g. `http://localhost:11434/v1` (Ollama), `http://vllm:8000/v1`, `https://openrouter.ai/api/v1`. `anthropic`: optional override for an internal gateway or proxy. | | For a single-key deployment, still use the keyring format: `SPARROW_ENCRYPTION_KEYS=main=<64-char-hex-key>` with `SPARROW_ENCRYPTION_PRIMARY_KEY_ID=main`. | | | | ### Web UI Variables [Section titled “Web UI Variables”](#web-ui-variables) These configure the web UI, not the Go server. They matter only when the UI is **not** served by Sparrow itself, i.e. under `npm run dev` or when you [host the UI separately](/sparrow/deployment/separate-ui/). With `SPARROW_SERVE_UI=true` the embedded UI uses the same origin; when `SPARROW_API_KEY` is set it shows a sign-in prompt on the first `401`. | Setting | Where | Default | Description | | ---------------- | ------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `apiUrl` | `window.__SPARROW_CONFIG__` in the UI’s `/config.js`, set on the static host at deploy time | — | Absolute URL of the Sparrow server. Overrides `PUBLIC_API_URL`. | | `apiKey` | `window.__SPARROW_CONFIG__` in `/config.js` | — | The master key (`SPARROW_API_KEY`) or a tenant-wide access token. Optional: without it, the UI shows a sign-in prompt on the first `401` and remembers the credential in the browser. Anyone who can load the UI can read a key placed here. | | `PUBLIC_API_URL` | Environment variable for `vite build` / `vite dev` | Same origin (`npm run build`), `http://localhost:8080` (`npm run dev`) | Sparrow server URL baked into the bundle at build time. Changing it later requires a rebuild. | ## Encryption [Section titled “Encryption”](#encryption) Sparrow encrypts webhook secrets and sensitive headers at rest using **envelope encryption** (AES-256-GCM). Each record gets its own random data encryption key (DEK), which is wrapped by a configured key encryption key (KEK). ### Key Management [Section titled “Key Management”](#key-management) Configure encryption with `SPARROW_ENCRYPTION_KEYS` and `SPARROW_ENCRYPTION_PRIMARY_KEY_ID`. The server will not start without both of these variables. The key material is **never** stored in the database. Storing the encryption key next to the data it protects defeats the purpose of encryption at rest. Each key value should be a cryptographically random 32-byte (256-bit) key, hex-encoded as 64 characters. `openssl rand -hex 32` is a suitable way to generate one. Use a secrets manager, Kubernetes Secret, or `.env` file to provide the key: ```bash # Single-key deployment in keyring form export SPARROW_ENCRYPTION_KEYS="main=$(openssl rand -hex 32)" export SPARROW_ENCRYPTION_PRIMARY_KEY_ID=main # Rotation-friendly multi-key deployment # key IDs must use only A-Z, a-z, 0-9, _ and - export SPARROW_ENCRYPTION_KEYS="old=$(openssl rand -hex 32),new=$(openssl rand -hex 32)" export SPARROW_ENCRYPTION_PRIMARY_KEY_ID=new ``` New writes use the primary key ID while decryption accepts all configured keys in the ring. That lets you rotate by adding a new key, switching the primary, and retiring the old key after data has been rewritten. ### What Gets Encrypted [Section titled “What Gets Encrypted”](#what-gets-encrypted) | Field | Stored As | Encrypted | | ------------------ | --------- | -------------- | | `webhook_secret` | BYTEA | Yes (envelope) | | `secret_headers` | BYTEA | Yes (envelope) | | Event payloads | JSONB | No (plaintext) | | Delivery responses | TEXT | No (plaintext) | ## Database Pools [Section titled “Database Pools”](#database-pools) Sparrow uses two connection pools: | Pool | Library | Config | Purpose | | ------- | -------------- | ---------------------------------------- | ----------------------- | | sqlx | `jmoiron/sqlx` | MaxOpen=25 | All application queries | | pgxpool | `jackc/pgx/v5` | MaxConns=50, MinConns=10, 30min lifetime | River job queue only | ## Observability [Section titled “Observability”](#observability) Sparrow exports traces, metrics, and logs via OpenTelemetry (OTLP). Set `OTEL_EXPORTER_OTLP_ENDPOINT` to point to your collector. OTLP/HTTP is the default: ```bash OTEL_EXPORTER_OTLP_ENDPOINT=http://your-otel-collector:4318 ``` To export over OTLP/gRPC instead, set the protocol and use the collector’s gRPC port: ```bash OTEL_EXPORTER_OTLP_PROTOCOL=grpc OTEL_EXPORTER_OTLP_ENDPOINT=http://your-otel-collector:4317 ``` The URL scheme controls TLS: `http://` sends plaintext, `https://` uses TLS. The other standard OpenTelemetry exporter variables work as well, for example `OTEL_EXPORTER_OTLP_HEADERS` for a hosted backend’s API key, `OTEL_EXPORTER_OTLP_CERTIFICATE` for a private CA, or `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT` to send one signal somewhere else. A bare `host:port` without a scheme is still accepted and is sent as plaintext. ### Exported Metrics [Section titled “Exported Metrics”](#exported-metrics) | Metric | Type | Description | | ------------------------------------- | ------------- | ---------------------------------------------- | | `sparrow_webhook_registrations_total` | Counter | Total number of webhook registrations | | `sparrow_events_pushed_total` | Counter | Total number of events pushed | | `sparrow_active_webhooks` | UpDownCounter | Current number of active webhook registrations | ## Default Tenant [Section titled “Default Tenant”](#default-tenant) A default tenant (`00000000-0000-0000-0000-000000000001`) is auto-created on startup. All operations use this tenant. The tenant infrastructure is retained for future multi-tenant support. Authentication is optional — set `SPARROW_API_KEY` to require a shared secret on all API requests. When unset, all endpoints are open (designed for internal deployments behind a VPN). When `SPARROW_API_KEY` is set, the embedded dashboard shows a sign-in prompt on the first `401` — see [Securing Sparrow](/sparrow/deployment/security/) for the trust model, SSO via an identity-aware proxy, and a hardening checklist. # How It Works > Understand Sparrow's core concepts — events, webhooks, subscriptions, and deliveries. Sparrow accepts webhook registrations and event definitions, fans out events to matching subscribers, and delivers them reliably with retries and health tracking. ## The Full Flow [Section titled “The Full Flow”](#the-full-flow) 1. **Register an event type** — tell Sparrow what events exist in your system. 2. **Register a webhook** — provide a URL and the events it should receive. Sparrow creates subscriptions automatically. 3. **Push an event** — when something happens, send the event payload to Sparrow: ```bash curl -X POST "http://localhost:8080/v1/consumers/default/events?event=order.created" \ -H "Content-Type: application/json" \ -H "X-API-Key: sk_live_..." \ -d '{ "payload": {"order_id": "ord_123", "total": 49.99}, "idempotency_key": "idem_ord_123" }' ``` 4. **Sparrow fans out** — the event worker finds all active subscriptions matching the event name, consumer, and **label filters**, applies any payload transforms, and creates a delivery job for each. 5. **Webhook delivery** — the webhook worker sends an HTTP POST to each URL with the payload, **Standard Webhooks signatures** (HMAC and/or Ed25519), and Sparrow headers. Failed deliveries are retried with exponential backoff. 6. **Track results** — query delivery status, health metrics, and error categories through the API or the web UI. ![Flow: Push Event goes to the Event Worker, which finds subscriptions and creates deliveries; the Webhook Worker then either records the result on success or retries with backoff on failure.](/sparrow/_astro/event-pipeline-light.DgUBHcme.svg) ![Flow: Push Event goes to the Event Worker, which finds subscriptions and creates deliveries; the Webhook Worker then either records the result on success or retries with backoff on failure.](/sparrow/_astro/event-pipeline-dark.CeRTOBJ2.svg) ## Core Concepts [Section titled “Core Concepts”](#core-concepts) ### Events [Section titled “Events”](#events) An **event** represents something that happened in your system — `order.created`, `user.signed_up`, `payment.failed`, etc. Event types can be registered explicitly, which lets you attach a description and a JSON Schema: ```bash curl -X POST http://localhost:8080/v1/event-types \ -H "Content-Type: application/json" \ -H "X-API-Key: sk_live_..." \ -d '{"name": "order.created", "description": "Fires when a new order is placed", "active": true}' ``` Event registrations act as a versioned schema registry. Pushing an unregistered event type returns `404` (set `SPARROW_AUTO_REGISTER_EVENTS=true` in development to create it on first push); pushing an event type that was deactivated is rejected. Changing a type’s schema creates a new version and keeps the old one, and event types are never deleted. See [Event Type Versions](/sparrow/guides/event-type-versioning/) and, to promote definitions between environments, [Moving Event Types Between Environments](/sparrow/guides/event-type-export-import/). ### Webhooks [Section titled “Webhooks”](#webhooks) A **webhook** is a registered HTTP endpoint that receives event notifications. When you register a webhook, you provide a URL and optionally a secret for HMAC signature verification: ```bash curl -X POST http://localhost:8080/v1/consumers/default/webhooks \ -H "Content-Type: application/json" \ -H "X-API-Key: sk_live_..." \ -d '{ "url": "https://your-app.com/webhooks", "events": ["order.created"], "active": true }' ``` When you include `events` during registration, Sparrow automatically creates subscriptions linking that webhook to those event types. ### Subscriptions [Section titled “Subscriptions”](#subscriptions) A **subscription** connects a webhook to an event type. It controls which events a webhook receives and can optionally transform the payload using Go templates. Subscriptions are created automatically when you register a webhook with `events`, but you can also manage them explicitly: ```bash curl -X POST http://localhost:8080/v1/consumers/default/subscriptions \ -H "Content-Type: application/json" \ -H "X-API-Key: sk_live_..." \ -d '{ "webhook_id": "YOUR_WEBHOOK_ID", "event_name": "order.created" }' ``` You can enable payload transforms on a subscription to reshape the event payload before delivery — useful for adapting events to third-party formats like Slack or PagerDuty. ### Deliveries [Section titled “Deliveries”](#deliveries) A **delivery** is a single attempt (or series of retry attempts) to send an event payload to a webhook URL. Sparrow tracks every delivery with: * HTTP status code and response body * Response time * Error classification (timeout, DNS, TLS, connection refused, etc.) * Retry count and next attempt time ## Key Design Decisions [Section titled “Key Design Decisions”](#key-design-decisions) * **Async by default** — the push-event endpoint (`POST /v1/consumers/{consumer}/events`) returns immediately. Processing and delivery happen in background workers via a persistent job queue (River). * **At-least-once delivery** — retryable failures (server errors, timeouts, connection issues, rate limiting) are automatically retried. Non-retryable failures (DNS errors, TLS errors, 4xx responses) are recorded and not retried. * **Per-webhook health tracking** — Sparrow monitors consecutive failures, success rates, and response times for each webhook independently, with a state machine (healthy → degraded → unhealthy). * **Cryptographic signing** — every delivery is signed in the [Standard Webhooks](https://www.standardwebhooks.com/) format: HMAC-SHA256 by default, or Ed25519 per webhook (Ed25519 webhooks carry both signatures, so consumers verify with a shared secret or a public key). * **10-category error classification** — failures are classified into specific categories (DNS, TLS, timeout, connection refused, rate limited, client error, server error, etc.) with retryability flags, so you know *why* a delivery failed. * **PostgreSQL only** — no Redis, no message broker. The River job queue runs inside PostgreSQL, keeping the operational footprint to a single database. * **Consumers** — webhooks and events are organized into consumers for logical separation (e.g., `billing`, `notifications`). A `default` consumer is always available. * **Soft schema validation** — event schemas produce warnings, not errors. Events are always accepted and stored, with a `schema_valid` flag for filtering. ## Common Real-World Use Cases [Section titled “Common Real-World Use Cases”](#common-real-world-use-cases) Sparrow simplifies architecture across multiple common software patterns: ### 1. Reliable Outbound Webhooks for B2B SaaS Platforms [Section titled “1. Reliable Outbound Webhooks for B2B SaaS Platforms”](#1-reliable-outbound-webhooks-for-b2b-saas-platforms) If you build a SaaS platform (like Stripe, GitHub, or Shopify) that sends customer-configured webhooks, Sparrow acts as your dedicated outbound gateway: * Customers register their endpoints and HMAC secrets. * Your backend pushes events to Sparrow with `idempotency_key` guarantees. * Sparrow signs each HTTP request with Standard Webhooks signatures, retries failures with exponential backoff, tracks endpoint health (healthy/degraded/unhealthy), and provides full delivery visibility. ### 2. Internal Microservice Event Bus & Fan-Out [Section titled “2. Internal Microservice Event Bus & Fan-Out”](#2-internal-microservice-event-bus--fan-out) Instead of building heavy Kafka or RabbitMQ integrations just to notify internal microservices: * Services emit domain events (e.g., `user.signup`) to Sparrow via simple REST calls. * Internal subscribers (Billing, Email Marketing, Analytics, Security Audit) register webhooks. * Subscriptions use label filters (`env=prod`) and payload transforms to ensure services only receive relevant, cleanly shaped data. ### 3. Automated Third-Party Integration Gateway [Section titled “3. Automated Third-Party Integration Gateway”](#3-automated-third-party-integration-gateway) With Sparrow’s [recipes](/sparrow/satellites/recipes/) and [satellites](/sparrow/satellites/), you can integrate core business events with Slack, PagerDuty, Discord, ClickHouse, or S3 with zero custom code: * Firing an event automatically creates PagerDuty incidents on critical errors. * Posts rich Block Kit cards into Slack channels on sales conversions. * Streams full transaction streams into S3/MinIO for audit compliance. # Installation > How to install and run Sparrow. ## Docker Compose (Recommended) [Section titled “Docker Compose (Recommended)”](#docker-compose-recommended) The simplest way to try Sparrow. No clone, no secrets, no `.env` needed: ```bash curl -O https://raw.githubusercontent.com/sarathsp06/sparrow/main/deploy/docker-compose.yml docker compose up -d ``` > \[!NOTE] This Compose file is for **local evaluation** — open API, a well-known encryption key, and the port bound to `127.0.0.1` only. For a real deployment, see [Production Deployment](/sparrow/deployment/production/). | Endpoint | URL | | -------- | ----------------------- | | Web UI | | | REST API | | To stop: ```bash docker compose down # stop containers docker compose down -v # stop and delete data ``` ## Build from Source [Section titled “Build from Source”](#build-from-source) ### Prerequisites [Section titled “Prerequisites”](#prerequisites) * Go 1.26+ * Node.js 22+ (for web UI) * PostgreSQL 15+ ### Build [Section titled “Build”](#build) * With UI ```bash make build-with-ui ``` * Server Only ```bash make build ``` ### Run [Section titled “Run”](#run) ```bash echo "DATABASE_URL=postgres://sparrow:sparrow@localhost:5432/sparrow?sslmode=disable" > .env # 32 cryptographically random bytes, hex-encoded as 64 chars echo "SPARROW_ENCRYPTION_KEYS=main=$(openssl rand -hex 32)" >> .env echo "SPARROW_ENCRYPTION_PRIMARY_KEY_ID=main" >> .env make migrate # apply schema make run # go run ./cmd/server, SPARROW_SERVE_UI=true; loads .env automatically ``` No local Postgres yet? `make dev-db` starts one (Docker) on the `DATABASE_URL` above. Without a `.env`, `make run` and `make migrate` default to that database and a dev-only all-zeros keyring; anything set in the shell or `.env` takes precedence. For UI hot reload, run `make run` in one terminal and `make run-web` (`cd web && npm run dev`, served at `localhost:5173`) in another. To run the full stack in containers instead, rebuilding from source on every change: ```bash make docker-dev # docker compose -f docker-compose.dev.yml up -d --build make docker-purge # tear down, including volumes ``` ## Pre-built Binaries [Section titled “Pre-built Binaries”](#pre-built-binaries) Every release ships cross-compiled binaries on the [GitHub releases page](https://github.com/sarathsp06/sparrow/releases/latest) — no Go toolchain required. Download the archive for your platform, extract it, and run: * **CLI** — the [`sparrow` command-line tool](/sparrow/satellites/cli/), in the `sparrow-cli-*` archive * **Server** — the Sparrow server, in the `sparrow-*` archive * **Satellites** — the [`sparrow-sources` and `sparrow-sinks`](/sparrow/satellites/) binaries Binaries are built for macOS and Linux (amd64 + arm64) and Windows (amd64). To build from source instead: ```bash make build-all ``` ## Docker Image [Section titled “Docker Image”](#docker-image) Pre-built multi-arch images (linux/amd64, linux/arm64) are published to GitHub Container Registry: Latest Docker release: [`ghcr.io/sarathsp06/sparrow:latest`](https://github.com/sarathsp06/sparrow/pkgs/container/sparrow?tag=latest). ```bash docker pull ghcr.io/sarathsp06/sparrow:latest ``` The Dockerfile uses a 3-stage build: 1. **Frontend** (`node:22-alpine`): Builds the SvelteKit UI 2. **Backend** (`golang:1.26-alpine`): Compiles Go binaries with embedded UI 3. **Runtime** (`distroless/static-debian12:nonroot`): Minimal production image # Quickstart > Get up and running with Sparrow in 5 minutes. This guide walks you through registering a webhook, pushing an event, and verifying delivery — all using `curl`. Note All examples use `http://localhost:8080` (REST/HTTP API). Replace with your server URL in production. Tip Running from source instead of a pre-built image? See [Build from Source](/sparrow/getting-started/installation/#build-from-source) for `make run` / `make docker-dev`. Note The `X-API-Key` header is only needed when the server was started with `SPARROW_API_KEY` set — add `-H "X-API-Key: "` if you have one, or omit it on the default local Compose (which runs without a key). All URLs use the `default` consumer (bootstrapped on startup); swap the `default` path segment for another consumer to isolate a different tenant’s webhooks and events. 1. **Register an event type** ```bash curl -X POST http://localhost:8080/v1/event-types \ -H "Content-Type: application/json" \ -H "X-API-Key: sk_live_..." \ -d '{ "name": "order.created", "description": "Fires when a new order is placed", "active": true }' ``` 2. **Register a webhook** ```bash curl -X POST http://localhost:8080/v1/consumers/default/webhooks \ -H "Content-Type: application/json" \ -H "X-API-Key: sk_live_..." \ -d '{ "url": "https://testhooks.sarathsadasivan.com/hooks", "events": ["order.created"], "active": true }' ``` Save the `webhook_id` from the response for later use. 3. **Push an event** ```bash curl -X POST "http://localhost:8080/v1/consumers/default/events?event=order.created" \ -H "Content-Type: application/json" \ -H "X-API-Key: sk_live_..." \ -d '{ "payload": { "order_id": "ord_123", "customer": "alice@example.com", "total": 49.99 }, "idempotency_key": "idem_ord_123", "ttl_seconds": 3600 }' ``` 4. **Check delivery status** ```bash curl "http://localhost:8080/v1/consumers/default/deliveries?webhook_id=YOUR_WEBHOOK_ID&limit=10" \ -H "X-API-Key: sk_live_..." ``` ## What Just Happened? [Section titled “What Just Happened?”](#what-just-happened) 1. Sparrow stored the event and enqueued it for async processing 2. The event worker found the matching subscription (created automatically when you registered the webhook with `events`) 3. A delivery job was created and the webhook worker sent an HTTP POST to your URL 4. The delivery status, response code, and response body were recorded ## Next Steps [Section titled “Next Steps”](#next-steps) * [Learn how events, webhooks, and deliveries fit together](/sparrow/getting-started/how-it-works/) * [Configure webhook secrets](/sparrow/reference/architecture/#verifying-webhook-signatures) for HMAC signature verification * [Add payload transforms](/sparrow/reference/template-functions/) using Go templates * Generate a client from the OpenAPI spec — see [Client Libraries](/sparrow/reference/client-libraries/) # Why Sparrow > What makes Sparrow different from other webhook platforms, and how it compares to Svix, Convoy, Hookdeck, AWS SNS, and building your own. Sparrow is a self-hosted webhook delivery platform built for teams that want full control over their webhook infrastructure. No per-message pricing, no vendor lock-in, no external dependencies beyond PostgreSQL. Sparrow is MIT-licensed, and all core features are available in the open-source codebase (including envelope encryption, webhook signing, retries, and health tracking). ## What Sparrow Gives You [Section titled “What Sparrow Gives You”](#what-sparrow-gives-you) Production-Grade Delivery At-least-once delivery with exponential backoff, 10-category error classification, per-webhook health tracking (healthy/degraded/unhealthy state machine), and automatic retry logic that distinguishes retryable failures from permanent ones. Cryptographic Signing Every delivery is signed in the [Standard Webhooks](https://www.standardwebhooks.com/) format — HMAC-SHA256 by default, or Ed25519 per webhook for public-key verification. Ed25519 keypairs are generated automatically on opt-in. Encryption at Rest Webhook secrets, signing keys, and sensitive headers are envelope-encrypted with AES-256-GCM and per-record data encryption keys. Not just “encrypted in the database” — actual envelope encryption with key hierarchy. Zero External Dependencies PostgreSQL is the only infrastructure requirement. No Redis, no message broker, no object storage. The [River](https://riverqueue.com) job queue runs inside PostgreSQL. One database to back up, monitor, and scale. Consumer Self-Service Portal Hand each consumer a scoped, expiring link to a portal where they register their own endpoints, manage subscriptions, and inspect and retry their own deliveries — no admin API key, no visibility into anyone else’s data. One admin call mints the token; it is stateless (HMAC-signed, revoked by expiry) and rides in the URL fragment. This is the [App Portal](/sparrow/guides/portal-embedding/) capability Svix is known for — built in, no extra service to run. ## Feature Comparison [Section titled “Feature Comparison”](#feature-comparison) ### Sparrow vs the Landscape [Section titled “Sparrow vs the Landscape”](#sparrow-vs-the-landscape) The table below compares Sparrow with the main alternatives for webhook delivery: [Svix](https://www.svix.com/) (open-source + cloud), [Convoy](https://getconvoy.io/) (source-available, Elastic 2.0), [Hookdeck](https://hookdeck.com/) (SaaS platform with open-source Outpost agent), [AWS SNS](https://aws.amazon.com/sns/) (managed), and building it yourself (DIY). Comparison scope Third-party capabilities and packaging can change over time. Treat this table as directional and verify critical details against each vendor’s latest docs. | Capability | Sparrow | Svix | Convoy | Hookdeck | AWS SNS | DIY | | --------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | --------------------------------------------- | ----------------------------------------------------- | -------------------------------------------- | -------------------------- | --------------------- | | **Fully open source (MIT)** | Yes | Partial (OSS core, paid features) | No (Elastic 2.0, source-available) | Partial (Outpost OSS, core platform closed) | No | Yes | | **Self-hosted** | Yes | Yes | Yes | Partial (Outpost self-hosted, platform SaaS) | No (Managed) | Yes | | **No vendor per-message pricing (self-hosted)** | Yes (self-hosted; infra only) | No | Yes (self-hosted) | No | No | Yes (your infra only) | | **Infra complexity (core deps)** | PostgreSQL only | PostgreSQL (+ Redis for HA) | PostgreSQL + Redis | SaaS | Managed | Varies | | **Job queue backend** | PostgreSQL (River) | Redis-backed queue/workers | Background workers + Redis | Managed | Managed | Varies | | **Webhook signing** | Dual HMAC-SHA256 + Ed25519 | HMAC-SHA256 | HMAC-SHA256 | HMAC-SHA256 | X.509 signature (not HMAC) | Manual | | **Security model for secrets** | Envelope encryption with per-record DEKs | Encrypted at rest (service-managed keys) | Deployment-dependent (no built-in envelope hierarchy) | Vendor-managed encryption | Vendor-managed encryption | Manual | | **Payload transformation** | Go templates per subscription (37 functions, incl. structural + arithmetic) | Varies by edition/plan | Yes (JS transform) | Yes (JS transform) | No | Manual | | **Per-webhook rate limiting** | Leaky bucket (DB-backed) | Available (details vary) | Yes | Yes | Regional quotas | Manual | | **Delivery semantics** | At-least-once + explicit idempotency model | At-least-once + retries | At-least-once + retries | At-least-once + replay | At-least-once | Varies | | **Delivery health model** | State machine (healthy/degraded/unhealthy) | Endpoint status | Circuit breaker | Dashboard metrics | CloudWatch | Manual | | **Error classification** | 10 protocol-aware categories with retryability flags | Basic status and error reporting | Success/failure | Categorized | CloudWatch | Manual | | **Bulk retry/re-push** | Deterministic snapshot-based (up to 10K) | Not in OSS | Manual retry | UI retry | No | Manual | | **Idempotent ingestion** | Built-in dedup with idempotency keys | Application-level (no OSS ingest dedup) | Event IDs | Dedup available | Message dedup (5min) | Manual | | **REST / OpenAPI API** | Yes (OpenAPI 3.1 spec) | Yes (REST) | Yes (REST) | Yes (REST) | No | Manual | | **Event schema validation** | Soft validation (warns, never rejects; server-side check on test pushes) | JSON Schema defs (not enforced) | No | Filters (not schema) | No | Manual | | **Prebuilt integration recipes** | Yes (Slack, Discord, PagerDuty, ntfy, ClickHouse, Twilio, SendGrid — one YAML, rendered server-side) | Limited | Limited | Yes (integrations) | Limited destinations | Manual | | **Inbound ingestion (verify provider signatures)** | Yes (`sparrow-sources`: Stripe, GitHub, cron schedules) | Yes (Ingest product) | Yes (incoming webhooks) | Yes (core feature) | No | Manual | | **Non-HTTP delivery (email, object storage, OTLP)** | Yes (`sparrow-sinks`: SMTP, S3/MinIO, OTLP logs) | Limited | Limited | Limited | SNS-native targets | Manual | | **Self-monitoring alerts** | Built-in system events (webhook health, delivery failure) + opt-in email alerts | Operational webhooks | Alert configs | Issue alerts | CloudWatch alarms | Manual | | **CLI tooling** | Yes (push, tail, local listen, apply recipes, debug templates) | Yes | Yes | Yes (localhost tunnels) | AWS CLI | Manual | | **Web dashboard** | Embedded SvelteKit | Included | Included | Included | Console | No | | **Tracing & metrics** | OpenTelemetry (traces + metrics + logs, job-level propagation) | Logging | Prometheus metrics | Dashboard | CloudWatch | Manual | | **SSRF protection** | Built-in (private IPs, redirects) | Built-in controls (plus proxy best practices) | Built-in controls / IP filtering | Managed | N/A | Manual | | **Client SDKs** | 3 (Go, Python, TypeScript) | Broad coverage | Multiple | Multiple | All AWS SDKs | 0 | | **Consumer app portal** | Yes (embeddable, token-scoped) | Yes (embeddable) | Yes (Portal Links) | Yes (Outpost/managed) | No | No | | **Multi-region / HA story** | PostgreSQL-native HA + stateless workers | Vendor/infra-managed | Vendor/infra-managed | Vendor-managed | Global managed service | Manual | | **Multi-tenant SaaS mode** | No (single-tenant focus) | Yes | Yes | Yes | Yes | Manual | ### Detailed Comparison: Sparrow vs Svix [Section titled “Detailed Comparison: Sparrow vs Svix”](#detailed-comparison-sparrow-vs-svix) Since Svix is the closest competitor architecturally, here’s a deeper look: | Capability | Sparrow | Svix OSS | | -------------------------------- | ------------------------------------------------- | ------------------------------- | | **Event schema validation** | Soft validation (warnings, never rejects) | JSON Schema defs (not enforced) | | **Consumer isolation** | Built-in logical separation | Application-level | | **API protocol** | REST/HTTP on :8080 (OpenAPI 3.1 spec) | REST API only | | **Deployment shape** | Single binary + Docker image | Broader deployment options | | **Container image** | Distroless (\~15MB), non-root | Standard | | **Consumer self-service portal** | Built-in, token-scoped — embed or hand off a link | App Portal (magic-link session) | ### Where Sparrow is stronger [Section titled “Where Sparrow is stronger”](#where-sparrow-is-stronger) * **Operational simplicity** — one database, one binary, no Redis. Fewer moving parts means fewer failure modes in production. * **Cryptographic depth** — Standard Webhooks signing (HMAC-SHA256 or Ed25519) and real envelope encryption are production security features that most webhook platforms skip or gate behind paid tiers. * **Payload transformation** — Go `text/template` transforms per subscription (37 built-in functions, including structural `dict`/`list`/`merge`/`append` and arithmetic) let you reshape payloads for different consumers (Slack, PagerDuty, custom formats) without proxy layers. This is a **deliberate design choice over an embedded JavaScript runtime**: templates are parsed once and cached, so each delivery runs with near-zero overhead; there is no per-worker JS VM to pool, no extra dependency, and the server stays a single distroless \~15MB binary with a small, predictable memory footprint. Parsed templates are concurrency-safe and reused across all delivery workers, so transform throughput scales with workers instead of contending on interpreter instances. Every transform is bounded by a 1MB output cap and a 5s execution timeout. Available to all users, not a paid feature. * **Error intelligence** — 10 error categories with retryability classification. You know *why* a delivery failed (DNS resolution? TLS handshake? Rate limited? Connection refused?), not just *that* it failed. * **Bulk operations** — snapshot-based batch re-push and retry with deterministic execution. What you filter is exactly what gets retried — no race conditions from new data arriving between search and action. * **Observability** — full OpenTelemetry integration with trace propagation through the job queue. Trace a single event from ingestion through fan-out to every delivery attempt. * **Idempotency** — built-in deduplication on event ingestion prevents double-processing without application-level workarounds. * **Rate limiting** — per-webhook delivery rate limiting with leaky bucket and HTTP 429 Retry-After parsing, all backed by PostgreSQL (no Redis). * **No Redis** — Sparrow uses PostgreSQL for everything including job queuing (via River). This keeps operational complexity lower for teams that want fewer infrastructure dependencies. * **Satellites** — companion tools built entirely on the public REST API: a [CLI](/sparrow/satellites/cli/) (push, tail, local listen, template debugging), [recipes](/sparrow/satellites/recipes/) (Slack, Discord, PagerDuty, ntfy, ClickHouse as one YAML file each, rendered server-side), [sources](/sparrow/satellites/sources/) (Stripe/GitHub signature verification + cron schedules re-published as typed events), and [sinks](/sparrow/satellites/sinks/) (SMTP email, S3/MinIO archives, OTLP log export). The core stays one small server; delete every satellite and Sparrow still works. * **Self-monitoring** — Sparrow emits its own system events (`sparrow.webhook.health_changed`, `sparrow.webhook.delivery_failed`) through the same pipeline it delivers with, and can email you about them via opt-in alert configs — your webhook infrastructure alerts on itself, no external monitoring stack required. * **Consumer self-service** — mint a scoped, expiring token for one consumer and hand them the [embedded portal](/sparrow/guides/portal-embedding/): they register endpoints, manage subscriptions, and inspect and retry their own deliveries with no API key and no visibility into any other consumer. Stateless HMAC tokens (signed with the encryption key, revoked by expiry) mean no session store and no extra service to run — the same end-user portal Svix ships as its App Portal, without the SaaS. ### Where alternatives are stronger [Section titled “Where alternatives are stronger”](#where-alternatives-are-stronger) * **Client SDK breadth** — Sparrow ships 3 generated SDKs today. Some alternatives provide broader first-party SDK coverage. * **Multi-tenant SaaS mode** — Svix, Convoy, and Hookdeck are designed for SaaS platforms where each customer manages their own webhooks. Sparrow is designed for teams running their own infrastructure. * **Managed cloud offering** — Svix Cloud and Hookdeck handle infrastructure for you. Sparrow is self-hosted only. * **JavaScript transforms** — Convoy and Hookdeck use JavaScript for payload transformation, which may be more familiar to web developers. Sparrow deliberately uses Go templates instead: cached, concurrency-safe execution with no embedded JS runtime means a smaller memory footprint, higher throughput, and zero extra dependencies — the tradeoff is JavaScript’s familiarity. The 37 built-in functions (structural map/list building, arithmetic, `dig`, type coercion) cover payload reshaping without a scripting engine. * **Ecosystem maturity** — alternatives may offer larger ecosystems and more prebuilt integrations. Sparrow’s recipes cover Slack, Discord, PagerDuty, ntfy, and ClickHouse today; anything else is a Go template away, but not prebuilt. ### Important nuance [Section titled “Important nuance”](#important-nuance) * **Hookdeck OSS scope** — Hookdeck Outpost is open source and can be self-hosted, while Hookdeck’s core platform remains SaaS. * **Comparison drift** — vendor plans and packaging change frequently; verify critical buying decisions against current vendor docs. ## Deployment Flexibility [Section titled “Deployment Flexibility”](#deployment-flexibility) Sparrow runs anywhere you can run one container and connect it to PostgreSQL: * **Docker Compose** for local development and small deployments * **Any container platform** — the distroless image is \~15MB with zero OS-level attack surface # API Documentation Guide > How the API reference is generated from the OpenAPI spec, and how schema fields produce rich documentation # API Documentation Guide [Section titled “API Documentation Guide”](#api-documentation-guide) The [API reference](/sparrow/reference/api/) is generated automatically from Sparrow’s OpenAPI spec (`api/openapi.yaml`), which is produced from the Go REST definitions in `internal/rest`. The richer the spec, the richer the generated reference. This page describes the OpenAPI schema fields Sparrow uses to produce descriptions, required-field markers, defaults, constraints, error codes, and examples. ## Descriptions [Section titled “Descriptions”](#descriptions) Every schema and property carries a `description`, which becomes the prose shown in the reference: ```yaml RegisterWebhookRequest: type: object description: | Creates a new webhook subscription. The webhook starts receiving events immediately after registration. ``` ## Required Fields [Section titled “Required Fields”](#required-fields) List required properties in the schema’s `required` array. They are flagged as required in the reference: ```yaml RegisterWebhookRequest: type: object required: [url] properties: url: type: string description: The URL to deliver webhook events to. ``` ## Deprecated Fields [Section titled “Deprecated Fields”](#deprecated-fields) Mark a property `deprecated: true` to flag it (and exclude it from the primary reference): ```yaml old_field: type: string deprecated: true description: Use new_field instead. ``` ## Default Values [Section titled “Default Values”](#default-values) Document defaults with the `default` keyword: ```yaml max_retries: type: integer default: 5 description: Maximum number of retry attempts. ``` ## Range Constraints [Section titled “Range Constraints”](#range-constraints) Use `minimum`/`maximum` to document valid ranges: ```yaml page_size: type: integer minimum: 1 maximum: 100 default: 50 description: Number of items per page. ``` ## Error Responses [Section titled “Error Responses”](#error-responses) Document error responses per operation under `responses`, keyed by HTTP status code: ```yaml responses: '409': description: A webhook with the same URL already exists. '400': description: The URL is malformed. ``` ## Examples [Section titled “Examples”](#examples) Provide example values with `example` (or `examples`). These are used to auto-generate the sample requests and responses shown in the reference: ```yaml url: type: string example: "https://example.com/webhook" max_retries: type: integer example: 5 active: type: boolean example: true ``` Example values are parsed as JSON where applicable; otherwise they are treated as strings. # Moving Event Types Between Environments > Export event type definitions from one Sparrow environment and import them into another, from the UI, the CLI or the API. An event type is usually designed and tested in development, then promoted through staging to production. Sparrow moves definitions between environments as one JSON file: select some or all event types in one environment, export them, and import the file in the next. 1. **Export** from the environment where the definitions are right. In the UI, tick the event types on the Events page and choose **Export selected**, or choose **Export all**. The browser downloads `event-types.json`. From the CLI: ```bash sparrow events export --prefix order. -f event-types.json ``` 2. **Preview** the import in the target environment. Nothing is written. ```bash sparrow events import -f event-types.json --dry-run ``` In the UI, choose **Import** on the Events page and pick the file; the preview appears straight away. 3. **Import.** Confirm in the UI, or run the command without `--dry-run`. ## The bundle file [Section titled “The bundle file”](#the-bundle-file) ```json { "apiVersion": "sparrow/v1", "kind": "EventTypeList", "stamp": { "sparrow_version": "1.4.0", "format": 1, "sha256": "9f2c…" }, "items": [ { "name": "order.created", "description": "A customer placed an order.", "event_schema": { "type": "object", "required": ["order_id", "total"], "properties": { "…": {} } }, "metadata": { "owner": "payments" }, "active": true } ] } ``` * One file holds any number of event types (up to 500 per import), sorted by name. * The file carries no version numbers, timestamps or sample payloads. Version numbers are per environment: development may be on v7 while production is on v3 of the same schema. Leaving them out means the same definitions always export to the same file, so it can be reviewed and diffed in a pull request. * Sparrow’s own `sparrow.*` event types are never exported or imported. * A file can be written by hand. Only `items` is required, and within each item only `name`. ## What an import does [Section titled “What an import does”](#what-an-import-does) Each entry **replaces** the event type with the same name, using the usual [version rules](/sparrow/guides/event-type-versioning/): a changed schema creates a new version, a first schema fills in version 1, other changes update in place, and an identical entry writes nothing. A field left out of an entry is cleared (a missing `event_schema` removes the schema, which is a breaking change), except `active`, which defaults to `true`. Event types that are **not in the file are never touched**. An import never deletes anything; there is no prune option. The whole file is applied in one transaction: every entry, or none. ![Import flow: validate every item, check the stamp, apply every item in one transaction, render affected templates for new versions, then either roll back and return the result (dry run, unacknowledged warning or unapproved breaking change) or optionally pause failing subscriptions and commit.](/sparrow/_astro/event-type-import-flow-light.BUhFvGGQ.svg) ![Import flow: validate every item, check the stamp, apply every item in one transaction, render affected templates for new versions, then either roll back and return the result (dry run, unacknowledged warning or unapproved breaking change) or optionally pause failing subscriptions and commit.](/sparrow/_astro/event-type-import-flow-dark.CMZT6-Vv.svg) ### The preview [Section titled “The preview”](#the-preview) Every import returns the full result, whether or not it was written: * each entry’s action (`created`, `new_version`, `updated`, `unchanged`), its version before and after, and which fields change, including whether it deactivates or reactivates the type; * for a new version, whether the change is [breaking](/sparrow/guides/event-type-versioning/#breaking-changes) and why; * for a new version, every subscription that receives the type, with its transform template rendered strictly against the new schema, twice: with a full sample payload and with only the required fields. The second catches a template that reads an optional field without checking it is there. Subscriptions without a transform are counted, since Sparrow cannot check what the receiving system does with the payload. ### When nothing is written [Section titled “When nothing is written”](#when-nothing-is-written) An import writes nothing, and says why in `blocked_by`, when: | Reason | Meaning | To proceed | | -------------------- | ------------------------------------------------------- | ------------------------------------------------------------------------------------ | | dry run | You asked for a preview. | Run it again without `dry_run`. | | `version_differs` | The file was exported by a different Sparrow version. | UI checkbox, `--accept-version-mismatch`, or `acknowledge: ["version_differs"]` | | `format_unsupported` | The file uses a bundle format newer than this server. | UI checkbox, `--accept-version-mismatch`, or `acknowledge: ["format_unsupported"]` | | `items_changed` | The items were edited after export. | UI checkbox, `--accept-edited`, or `acknowledge: ["items_changed"]` | | `breaking` | A schema change is breaking for existing subscriptions. | Type each event type’s name in the UI, `--allow-breaking`, or `allow_breaking: true` | The CLI exits non-zero when an import is blocked, and when a dry run would be blocked, so a promotion script stops at the right point. The stamp is a compatibility hint, not a signature Each export is stamped with the exporting Sparrow version, the bundle format, and a sha256 of its items, computed from their content so reformatting the file does not change it. It tells you whether the file came from a matching Sparrow and whether someone edited it. It does not prove who made the file: anyone can edit a file and recompute the digest. A file with no stamp, such as a hand-written one, imports with a notice and needs no acknowledgement. ### Subscriptions whose template fails [Section titled “Subscriptions whose template fails”](#subscriptions-whose-template-fails) By default subscriptions keep running after an import. A template that no longer fits the new schema then fails its deliveries with error category `template_error`: nothing wrong is sent, nothing is retried automatically, and the webhook’s health is unaffected. Fix the template, then retry the failed deliveries. To hold deliveries instead, import with `--pause-affected` (UI: **Pause the subscriptions whose template fails**; API: `subscription_policy: "pause"`). Each failing subscription is paused in the same transaction, with the import as the reason. See [Pausing a Subscription](/sparrow/guides/subscription-pause/). ## API [Section titled “API”](#api) ```bash # Export by name, by prefix, or everything curl -X POST http://localhost:8080/v1/event-types:export \ -H 'Content-Type: application/json' -d '{"names": ["order.created", "order.shipped"]}' # Import: the exported file as-is, plus options jq '. + {dry_run: true}' event-types.json | curl -X POST http://localhost:8080/v1/event-types:import \ -H 'Content-Type: application/json' -d @- ``` Import options: `dry_run`, `acknowledge` (a list of stamp warnings you accept), `allow_breaking`, and `subscription_policy` (`keep_active` or `pause`). The response has `applied`, `imported_at`, `stamp`, `blocked_by` and one entry per item in `items`. An invalid file (duplicate names, reserved names, a schema that does not compile, too many items) is rejected with `400` listing every problem, and nothing is imported. # Event Type Versions > How event type definitions change over time, why they are never deleted, and what Sparrow checks before a schema change reaches subscribers. An event type is a contract between the systems that push events and the subscriptions that receive them. Sparrow treats it that way: every schema the type has ever had is kept as a numbered version, each pushed event records the version it was accepted under, and a type is never deleted. You always refer to an event type by its name. The version is bookkeeping: push, subscriptions and the API resolve the name to the current version. ![Lifecycle of an event type: unregistered, then version 1 (blank if auto-registered, filled in by the first schema without a new version), then version 2 on a compatible schema change, version 3 on a breaking change applied with allow\_breaking, then deactivated and reactivated without changing the version.](/sparrow/_astro/event-type-lifecycle-light.D76JuRDT.svg) ![Lifecycle of an event type: unregistered, then version 1 (blank if auto-registered, filled in by the first schema without a new version), then version 2 on a compatible schema change, version 3 on a breaking change applied with allow\_breaking, then deactivated and reactivated without changing the version.](/sparrow/_astro/event-type-lifecycle-dark.BWzMKBTA.svg) ## What creates a new version [Section titled “What creates a new version”](#what-creates-a-new-version) Only a change to the schema creates a new version. Everything else changes the current version in place. | Change | Result | | ------------------------------------------------------------------------------- | -------------------------------------------------------------------------- | | The name does not exist yet | `created`: version 1 | | A schema is added to a type that had none | `updated`: version 1 is filled in, and `schema_defined_at` records when | | The schema changes (compared by value, so key order and whitespace never count) | `new_version`: the version number goes up and the previous version is kept | | The schema is removed | `new_version`, and it is a breaking change | | Only the description, metadata or `active` changes | `updated`: same version | | Nothing changes | `unchanged`: nothing is written | `PATCH /v1/event-types/{name}` returns a `change` object saying which of these happened, with `version`, `previous_version` and the list of changed fields. Why filling in a schema is not a new version A type with no schema has no contract, so adding the first schema defines the contract rather than changing it. This keeps environments aligned: a type auto-registered in development, then imported with its real schema, is at version 1 everywhere. ![Decision flow for saving an event type: reserved names are rejected, the current row is locked, a missing type is created at v1, a first schema fills in v1, a changed schema is classified and either refused with 409 when breaking for subscribers or saved as a new version, other changes update in place, and identical definitions write nothing.](/sparrow/_astro/event-type-save-rules-light.Dn4b0FPI.svg) ![Decision flow for saving an event type: reserved names are rejected, the current row is locked, a missing type is created at v1, a first schema fills in v1, a changed schema is classified and either refused with 409 when breaking for subscribers or saved as a new version, other changes update in place, and identical definitions write nothing.](/sparrow/_astro/event-type-save-rules-dark.D-Z3SsLr.svg) Every write (register, PATCH, import, and auto-register on push) goes through the same rules, in a transaction that locks the event type’s row, so two concurrent changes can never claim the same version number. ## Reading the history [Section titled “Reading the history”](#reading-the-history) ```bash # Every version, newest first curl http://localhost:8080/v1/event-types/order.created/versions # One version's schema and sample payload curl http://localhost:8080/v1/event-types/order.created/versions/2 ``` The event type’s own response carries `version`, and every pushed event carries `event_version`: the version whose schema it was validated against. A batch re-push keeps the original event’s version. In the UI, the version in the event list links to the type’s history. ## Breaking changes [Section titled “Breaking changes”](#breaking-changes) Before a new version is written, Sparrow compares the old and new schemas from a subscriber’s point of view. The rule is strict: **anything that could break a subscription’s payload transformation is breaking**, and anything Sparrow cannot prove safe counts as breaking. | Compatible | Breaking | | ---------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | | Adding a property, optional or required | Removing a required property | | Removing an optional property | Making a required property optional | | Narrowing a type (`number` to `integer`, or adding a type where there was none) | A type that admits a new kind of value (`string` to `integer`, `integer` to `number`, object to array) | | Changing value constraints: `enum`, `minimum`, `maxLength`, `pattern`, `format`, … | Removing the schema | | | Any change under `oneOf`, `anyOf`, `allOf`, `not`, `$ref`, `patternProperties`, `if`/`then`/`else` or `dependentSchemas` | Nested objects and array items are checked the same way, and every reason names its path, for example `customer.email: removed required property`. A breaking change to an event type that **any subscription receives** (by name, or through a catch-all `*` subscription) is refused with `409 Conflict` and the reasons, and nothing is written. To apply it anyway, say so explicitly: ```bash curl -X PATCH 'http://localhost:8080/v1/event-types/order.created?allow_breaking=true' \ -H 'Content-Type: application/json' \ -d '{"event_schema": {"type": "object", "properties": {"id": {"type": "string"}}}}' ``` In the UI, saving a breaking change asks you to type the event type’s name. If no subscription receives the type there is nothing to break, so no opt-in is needed; the change is still classified and reported. Subscriptions without a transform The check is about transform templates, which Sparrow can see. A subscription without a transform sends the payload as is, and Sparrow cannot tell whether the receiving system copes with the new shape. Those subscriptions are counted in the result so you can check them yourself. When a schema change does reach a subscription whose template no longer fits, the delivery fails visibly with error category `template_error`; see [Transforming Payloads](/sparrow/guides/payload-transformation/#when-a-template-fails). ## Retiring an event type [Section titled “Retiring an event type”](#retiring-an-event-type) Event types are never deleted, and there is no delete endpoint. A definition with history cannot be removed at the database level either. To retire one, deactivate it: ```bash curl -X PATCH http://localhost:8080/v1/event-types/order.legacy \ -H 'Content-Type: application/json' -d '{"active": false}' ``` Pushes of an inactive type are rejected with `409`. Its versions and past events are kept, and setting `active` back to `true` reactivates it. ## Unregistered event names [Section titled “Unregistered event names”](#unregistered-event-names) By default, pushing an event whose type is not registered fails with `404`: production event types arrive by registering or [importing](/sparrow/guides/event-type-export-import/) them. Because event types are never deleted, creating them implicitly would turn every producer typo into a permanent name. For local development, set `SPARROW_AUTO_REGISTER_EVENTS=true` (`make run` does) to create a schema-less type on first push instead. The CLI’s `sparrow push` registers an unknown type and retries on its own. ## Reserved names [Section titled “Reserved names”](#reserved-names) Names starting with `sparrow.` (in any case) belong to Sparrow’s own system events, such as `sparrow.webhook.health_changed`. You can subscribe to them, but you cannot register, change, import, export or push them. # Transforming Payloads > A beginner-friendly guide to reshaping webhook payloads with Sparrow's template functions, with real-world examples. New to Sparrow? This guide shows you how to change the shape of a webhook payload *before* it’s delivered — no code, no proxy service, just a small template you write once per subscription. ## What is a transform, and why would I want one? [Section titled “What is a transform, and why would I want one?”](#what-is-a-transform-and-why-would-i-want-one) A webhook payload is just a chunk of JSON. The problem is that the system Sparrow delivers to rarely wants the *exact* JSON your event started with: * Slack wants a `{"text": "..."}` message, not your raw event. * Your billing system wants amounts in **cents**, but your event has **dollars**. * A legacy endpoint wants a flat object, but your event is deeply nested. * A partner should only see three fields, not your entire internal record. A **transform** solves this. You attach a small template to a subscription, and Sparrow runs your event through it to produce the body that actually gets sent. One subscription, one shape Every subscription can have its own transform. The *same* event can be delivered to Slack as a chat message and to your data warehouse as flat JSON — each subscription reshapes it independently. ## Where the transform lives [Section titled “Where the transform lives”](#where-the-transform-lives) When you create or update a subscription, set its `transform_template` field. That’s it — Sparrow handles the rest at delivery time. ## The mental model (read this first) [Section titled “The mental model (read this first)”](#the-mental-model-read-this-first) A template **receives your event** and **prints text**. Whatever text it prints becomes the delivered body. Your event is available through these fields: | Field | What it is | | ------------- | ---------------------------------------- | | `.payload` | Your event’s data (the JSON you pushed) | | `.event_name` | The event type, e.g. `payment.succeeded` | | `.event_id` | Unique ID for this event | | `.timestamp` | When the event happened | | `.attempt` | Which delivery attempt this is | So `{{ .payload.customer.email }}` reads the `customer.email` field out of your event, and `{{ .event_name }}` prints the event type. A missing field is an error By default a template that reads a field the payload does not have **fails** instead of printing ``, so a field removed from an event type’s schema cannot silently put a wrong value in the body. Read fields that may be absent with `dig` or `index`, which never fail on a missing key: `{{ dig "coupon" "" .payload }}` or `{{ index .payload "coupon" }}`. `default` does not help here: in `default "x" .payload.coupon` the lookup fails before `default` runs. See [When a template fails](#when-a-template-fails). Templates print text — use \`json\` for JSON A template outputs raw text. If you want to deliver **valid JSON**, build a map or list and pipe it through the `json` function (shown below). Don’t hand-write JSON with `{}` and quotes around your values — you’ll hit escaping bugs the moment a value contains a quote. Let `json` do it. ## Try it before you ship it [Section titled “Try it before you ship it”](#try-it-before-you-ship-it) You don’t have to guess. Test any template against a sample event: 1. **List the available helpers:** ```bash curl http://localhost:8080/v1/template-functions ``` 2. **Test a template against an event type’s sample payload** (returns the rendered output; it renders strictly, as a subscription does by default): ```bash curl -X POST http://localhost:8080/v1/subscriptions:testTemplate \ -H 'Content-Type: application/json' \ -d '{ "event_name": "payment.succeeded", "template": "{{ dict \"id\" .event_id \"amount\" .payload.amount | json }}" }' ``` 3. **Or entirely offline with the CLI** (no server round-trip; renders locally against a synthetic sample context instead of a stored event payload): ```bash sparrow template test partner.tmpl --payload '{"amount": 4200}' ``` Add `--missing-key zero` to render missing fields as ``, like a subscription with `template_missing_key: zero`. ## Real-world examples [Section titled “Real-world examples”](#real-world-examples) Each example shows the **incoming event**, the **template** you’d set on the subscription, and the **delivered result**. ### 1. Send only the fields a partner needs [Section titled “1. Send only the fields a partner needs”](#1-send-only-the-fields-a-partner-needs) **Problem:** your event has 30 internal fields, but a partner should only see three. **Incoming `.payload`:** ```json { "id": "evt_123", "amount": 4200, "internal_notes": "…", "secret": "…" } ``` **Template:** ```go {{ dict "id" .payload.id "amount" .payload.amount "event" .event_name | json }} ``` **Delivered:** ```json {"amount":4200,"event":"payment.succeeded","id":"evt_123"} ``` `dict` builds an object from `"key" value` pairs; `json` turns it into JSON. ### 2. Post a message to Slack [Section titled “2. Post a message to Slack”](#2-post-a-message-to-slack) **Problem:** Slack’s Incoming Webhooks expect `{"text": "..."}`. **Incoming `.payload`:** ```json { "customer": { "name": "Ada" }, "amount": 4200, "currency": "usd" } ``` **Template:** ```go {{ dict "text" (printf "%s paid %s %.2f" (dig "customer" "name" "Someone" .payload) (upper .payload.currency) (div .payload.amount 100)) | json }} ``` **Delivered:** ```json {"text":"Ada paid USD 42.00"} ``` Here `dig` safely reads `customer.name` (falling back to `"Someone"` if it’s missing), `div` converts cents to dollars, and `printf` formats the sentence. ### 3. Convert dollars to cents for a billing system [Section titled “3. Convert dollars to cents for a billing system”](#3-convert-dollars-to-cents-for-a-billing-system) **Problem:** your event has amounts in dollars; the target wants integer cents. **Incoming `.payload`:** ```json { "customer": { "id": "cus_9" }, "amount": 42.5, "currency": "usd" } ``` **Template:** ```go {{ dict "user" (dig "customer" "id" "" .payload) "amount_cents" (toInt (mul .payload.amount 100)) "currency" .payload.currency | json }} ``` **Delivered:** ```json {"amount_cents":4250,"currency":"usd","user":"cus_9"} ``` `mul` multiplies, `toInt` drops the decimal so you get a clean integer. ### 4. Flatten a nested payload for a legacy endpoint [Section titled “4. Flatten a nested payload for a legacy endpoint”](#4-flatten-a-nested-payload-for-a-legacy-endpoint) **Problem:** an old system wants a flat object, but your data is nested. **Incoming `.payload`:** ```json { "customer": { "id": "c1", "email": "ada@example.com" }, "order": { "total": 99.5 } } ``` **Template:** ```go {{ dict "customer_id" (dig "customer" "id" "" .payload) "email" (dig "customer" "email" "" .payload) "total" (dig "order" "total" 0 .payload) | json }} ``` **Delivered:** ```json {"customer_id":"c1","email":"ada@example.com","total":99.5} ``` `dig` walks a path of keys and returns your default if any part is missing — so one absent field never breaks the whole delivery. ### 5. Summarize a list of items [Section titled “5. Summarize a list of items”](#5-summarize-a-list-of-items) **Problem:** your event has an array of line items; you only want the SKUs. **Incoming `.payload`:** ```json { "items": [ { "sku": "A" }, { "sku": "B" }, { "sku": "C" } ] } ``` **Template:** ```go {{ $skus := list }}{{ range .payload.items }}{{ $skus = append $skus .sku }}{{ end }}{{ dict "skus" $skus | json }} ``` **Delivered:** ```json {"skus":["A","B","C"]} ``` `range` loops over the array; `append` collects each SKU into a growing list. ### 6. Add a constant or computed field [Section titled “6. Add a constant or computed field”](#6-add-a-constant-or-computed-field) **Problem:** you want to pass the whole payload through, but tag it with extra metadata. **Template:** ```go {{ merge .payload (dict "source" "sparrow" "delivered_at" (formatTime "2006-01-02T15:04:05Z07:00" now)) | json }} ``` **Delivered:** your original payload, plus `"source"` and `"delivered_at"`. `merge` combines maps; later keys win, and the originals are left untouched. ### 7. Provide safe defaults for optional fields [Section titled “7. Provide safe defaults for optional fields”](#7-provide-safe-defaults-for-optional-fields) **Problem:** some events are missing optional fields, and you want sensible fallbacks instead of empty values. **Template:** ```go {{ dict "name" (dig "customer" "name" "Guest" .payload) "plan" (default "free" (index .payload "plan")) | json }} ``` `dig` covers missing nested keys. `index` reads a top-level key without failing when it is absent, and `default` then covers a field that is missing or present but empty. ## Good to know [Section titled “Good to know”](#good-to-know) * **Transforms run at delivery**, on the way out. They don’t change what Sparrow stores or how it fans out — only the body each subscription receives. * **A template that fails is visible.** See [When a template fails](#when-a-template-fails). * **There are limits.** Output is capped at **1 MB** and execution at **5 seconds** per transform, so a runaway template can’t stall a worker. * **Why Go templates and not JavaScript?** They’re parsed once and cached, run with almost no overhead, add zero dependencies, and are safe to share across every delivery worker — a deliberate choice for a small memory footprint and high throughput. See [Why Sparrow](/sparrow/getting-started/why-sparrow/). ## When a template fails [Section titled “When a template fails”](#when-a-template-fails) A template fails when it reads a field the payload does not have (with the default `template_missing_key: error`), calls a function with the wrong kind of value, or exceeds the limits above. Two settings on the subscription decide what happens. | Setting | Values | | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `on_transform_error` | `fail` (default): nothing is sent. The delivery is marked failed with error category `template_error`, is **not retried automatically**, and keeps the error in `template_error`. `fallback`: the standard event envelope is sent instead, and the error is still recorded on the delivery. | | `template_missing_key` | `error` (default): reading a missing field fails the template. `zero`: it prints `` and the delivery goes out. | ```bash curl -X PATCH http://localhost:8080/v1/consumers/acme/subscriptions/{subscription_id} \ -H 'Content-Type: application/json' \ -d '{"on_transform_error": "fallback", "template_missing_key": "zero"}' ``` A template error is a problem on the sending side, not with the receiving system, so it **never counts toward the webhook’s health** and raises no health alert. It is counted in the `sparrow_template_errors_total` metric. To recover, fix the template, then retry: one delivery with `POST /v1/consumers/{consumer}/deliveries/{delivery_id}:retry`, or all of them by listing `?status=failed&error_category=template_error&prepare_retry=true` and starting a batch retry. Every attempt renders the subscription’s current template, so the retry uses the fixed one. In the UI, filter Deliveries by the **Template Error** category. When an event type’s schema changes, Sparrow checks every subscription’s template against the new schema before the change is written; see [Event Type Versions](/sparrow/guides/event-type-versioning/#breaking-changes). ## Where to go next [Section titled “Where to go next”](#where-to-go-next) * [Template Functions reference](/sparrow/reference/template-functions/) — the full list of all 37 helpers with signatures and examples. * [Recipes](/sparrow/satellites/recipes/) — ready-made source/sink configs that use transforms. # Embedding the Consumer Portal > Give external consumers a self-service webhook portal while Sparrow stays on a private network — full walkthrough of the proxy-the-slice topology with nginx and Express examples, plus the security analysis. Sparrow’s consumer portal (`/portal`) lets each consumer manage their own webhooks, subscriptions, and deliveries with a scoped, expiring **portal token** instead of the admin API key. This guide shows the recommended way to serve it to **external users while Sparrow itself stays VPN-only**: your product’s public app acts as a forwarding proxy for exactly the routes the portal needs — nothing else. The result: the visitor’s browser only ever talks to *your* domain, Sparrow is never directly reachable from the internet, and the only credential in the browser is a token that can touch one consumer and nothing more. ## How it works, end to end [Section titled “How it works, end to end”](#how-it-works-end-to-end) ![Sequence diagram: the consumer's browser logs in to yourapp.com; yourapp mints a consumer token (POST /v1/tokens) from sparrow:8080 over the private network with the admin API key; the token link is handed back to the browser; the browser loads the portal HTML and SPA bundle proxied from Sparrow; then the browser calls the API with a bearer token, which the proxy forwards and Sparrow verifies and scopes to the consumer.](/sparrow/_astro/portal-embedding-flow-light.D4smyCzj.svg) ![Sequence diagram: the consumer's browser logs in to yourapp.com; yourapp mints a consumer token (POST /v1/tokens) from sparrow:8080 over the private network with the admin API key; the token link is handed back to the browser; the browser loads the portal HTML and SPA bundle proxied from Sparrow; then the browser calls the API with a bearer token, which the proxy forwards and Sparrow verifies and scopes to the consumer.](/sparrow/_astro/portal-embedding-flow-dark.WAyhOZT2.svg) 1. **Your app authenticates the user** with whatever login it already has. Sparrow plays no part in identity — it never needs user accounts. 2. **Your backend mints a token** over the private network: ```bash curl -s -X POST http://sparrow:8080/v1/tokens \ -H "X-API-Key: $SPARROW_API_KEY" -H "Content-Type: application/json" \ -d '{"name": "portal", "consumer": "acme", "ttl_seconds": 3600, "external_id": "user-42"}' # → { "token": { "id": "tok_...", "expires_at": "...", ... }, "secret": "sparrow_tk_...", # "reused": false, "portal_path": "/portal#token=sparrow_tk_...&consumer=acme&expires=..." } ``` This is the **only** call that uses the admin key, and it happens server-to-server — the key never crosses the network boundary. The link is a consumer-scoped access token (the same kind `sparrow tokens create --consumer` makes): pick a short `ttl_seconds` (the default is 7 days), and revoke it early with `DELETE /v1/tokens/{id}`. `external_id` is your id for who the link is for (your user id, say). There is at most one active token per consumer and `external_id`, which makes the call safe to repeat on every page view: while that user’s link is valid, Sparrow returns the same token (`"reused": true`) instead of minting another, and mints a fresh one once it has expired or been revoked. 3. **Your app hands the link to the browser** — redirect to `https://yourapp.com/portal#token=...`, or render it in an `