A webhook gateway with HMAC-signed ingress, jq transforms, and a durable retry queue. Single binary, SQLite-backed, config-as-code.
inbound HTTP
─► HMAC signature verify
─► enqueue (SQLite)
─► worker picks up
─► jq transform
─► POST to destination (per-route timeout)
─► success: log delivery, mark done
─► failure: exponential backoff retry
─► budget exhausted: dead-letter
Routes are loaded from a YAML file at startup. Each route binds a source_path, a signature scheme, a jq filter, and a destination URL with retry policy. Secrets are referenced by environment variable name in the config and resolved at load time.
MVP. End-to-end happy path, retry, dead-letter, and replay are all verified. See Not yet implemented for what's deliberately missing.
Requires Rust 1.85+ (edition 2021), and cargo.
git clone <your-fork-url> sqlhook
cd sqlhook
cargo build --releaseWrite a config (config.yaml):
server:
bind: 127.0.0.1:8080
database_url: sqlite://./data/sqlhook.db?mode=rwc
worker:
poll_interval_ms: 500
concurrency: 4
routes:
- id: github-push
source_path: /gh/push
signature:
header: X-Hub-Signature-256
algo: hmac-sha256
secret_env: GH_WEBHOOK_SECRET
prefix: "sha256="
transform: |
{
repo: .repository.full_name,
pusher: .pusher.name,
sha: .after,
commits: (.commits | length)
}
destination:
url: https://internal.example.com/notify
timeout_ms: 5000
retry:
max_attempts: 5
initial_backoff_ms: 500
max_backoff_ms: 60000Run:
mkdir -p data
GH_WEBHOOK_SECRET='your-shared-secret' \
./target/release/sqlhook serve --config config.yamlSend a signed event:
SECRET='your-shared-secret'
BODY='{"repository":{"full_name":"acme/widgets"},"pusher":{"name":"alice"},"after":"abc123","commits":[{"id":"abc123"}]}'
SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$SECRET" -hex | awk '{print $2}')
curl -X POST http://127.0.0.1:8080/ingest/gh/push \
-H "Content-Type: application/json" \
-H "X-Hub-Signature-256: sha256=$SIG" \
-d "$BODY"sqlhook serve --config <path> # start HTTP server + worker pool
sqlhook validate-config --config <path> # parse + resolve secrets; print "ok" or fail
sqlhook transform-test --config <path> --route <id> # stdin JSON → transform → stdout
sqlhook replay --config <path> <job-id> # re-queue a dead-lettered job
transform-test is the fastest way to iterate on a jq filter:
echo '{"user":{"name":"alice"},"items":[1,2,3]}' \
| sqlhook transform-test --config config.yaml --route smoke
# {
# "count": 3,
# "name": "alice"
# }GET /health— returns{"status":"ok"}once the server is bound and the worker is running.GET /metrics— Prometheus text format. Useful counters:sqlhook_ingest_accepted_totalsqlhook_ingest_signature_failed_totalsqlhook_ingest_unknown_route_totalsqlhook_ingest_bad_payload_totalsqlhook_delivery_attempts_total{route, outcome}— outcome issuccessorfailuresqlhook_delivery_retries_totalsqlhook_delivery_dead_total
server:
bind: <host:port> # required
database_url: sqlite://<path>?mode=rwc # required; rwc creates the file if absent
worker:
poll_interval_ms: <u64> # default 500
concurrency: <usize> # default 4
routes:
- id: <string> # unique
source_path: /<path> # must start with /; unique
signature:
header: <header-name>
algo: hmac-sha256 # only scheme supported in the MVP
secret_env: <ENV_VAR> # resolved at startup; must be set
prefix: <string> # optional; stripped from header value before hex-decoding
transform: |
<jq expression> # compiled once per process via `jaq`
destination:
url: <https-or-http-url>
timeout_ms: <u64> # default 5000
retry:
max_attempts: <u32> # default 5
initial_backoff_ms: <u64> # default 500
max_backoff_ms: <u64> # default 60000src/
main.rs # CLI dispatch + serve()/replay()/transform-test()
cli.rs # clap definitions
config/ # YAML loader + secret resolution
error.rs # AppError + AppResult
signature.rs # HMAC-SHA256 verify (constant-time)
transform.rs # jaq compile/apply wrapper
delivery.rs # reqwest client with per-call timeout
retry.rs # exponential backoff (capped)
queue/
mod.rs # Queue trait + Job/JobOutcome types
sqlite.rs # claim-via-UPDATE-RETURNING implementation
metrics.rs # prometheus registry
server/
mod.rs # axum Router assembly
health.rs # GET /health
metrics.rs # GET /metrics
ingest.rs # POST /ingest/{*path}
worker.rs # claim → transform → deliver → report
migrations/
0001_init.sql # jobs + deliveries tables
The jobs table has four statuses: pending, processing, done, dead. Workers claim with a single statement that's safe under contention (SQLite serializes writes):
UPDATE jobs
SET status = 'processing', attempts = attempts + 1, updated_at = ?
WHERE id = (
SELECT id FROM jobs
WHERE status = 'pending' AND visible_at <= ?
ORDER BY visible_at
LIMIT 1
)
RETURNING id, route_id, source_path, raw_payload, attemptsOn failure with retry budget remaining, the worker writes the next visible_at and flips the status back to pending. On exhaustion, the row is set to dead and can be re-queued via sqlhook replay <id>.
Every attempt writes one row to deliveries (audit log), with the transformed payload, destination URL, response code/body, error, and duration — useful for forensics and for understanding why a delivery failed.
The MVP targets single-node deployments. SQLite handles the queue, audit log, and visibility-timer state in one file with no operational dependency. If you need multi-node, the natural upgrade path is Postgres (FOR UPDATE SKIP LOCKED replaces the SQLite UPDATE ... RETURNING claim); the Queue trait is the seam. Litestream/LiteFS can give you durable replication on top of SQLite for a wider set of failure modes without changing the data model.
The original duckdb-webhook-gateway (Python + FastAPI + DuckDB + React) demonstrates the same core idea — a programmable pipeline between inbound webhooks and downstream services. sqlhook reimplements the idea with different design choices:
| Axis | duckdb-webhook-gateway | sqlhook |
|---|---|---|
| Language | Python | Rust |
| Transform engine | DuckDB SQL with {{payload}} templating |
jaq (jq-compatible), compiled once per route |
| State | DuckDB for everything | SQLite for queue + audit; no analytics DB |
| Topology | FastAPI BackgroundTasks (fire-and-forget) |
Durable queue + worker pool, survives restarts |
| Inbound auth | Single API key on control plane | HMAC signature verify per event, per route |
| Retry | None | Exponential backoff with cap + dead-letter |
| Config | Stored in DB, mutated via REST | YAML loaded at startup (config-as-code) |
| UI | React SPA | None — CLI + Prometheus + structured logs |
| Audit | One row per event | One row per delivery attempt |
If you want a UI for managing routes and an interactive SQL playground, use upstream. If you want a single binary you can drop on a server that survives restarts, verifies signatures, and retries durably, this is what sqlhook is for.
Deliberately deferred from the MVP:
- Multiple signature schemes. Only
hmac-sha256with bare hex digest is supported. Stripe'st=...,v1=...format and Slack'sv0:timestamp:bodyneed scheme-specific parsers. - Hot config reload. Edits to
config.yamlrequire a process restart. - Multi-tenancy / quotas. No per-tenant scoping, no rate limits.
- Outbound mTLS or per-route auth headers. The destination request goes out as a plain
POSTwithContent-Type: application/json. - Distributed deployment. Single-node only; see Why SQLite.
- Schema validation of payloads. Transforms are responsible for shape; bad input produces a transform error and a dead-lettered job.
- Replay-from-DLQ in bulk.
sqlhook replaytakes one job id at a time.
cargo test # 7 unit tests covering signature, transform, retry math
cargo build # debug build
cargo build --release # release binary at ./target/release/sqlhookLogging is via tracing; set RUST_LOG=sqlhook=debug,info to see worker decisions.
Apache-2.0. See LICENSE (matches the upstream project's license).