webhooksmith
Webhook delivery for Rust backed by Postgres or SQLite. Atomic outbox writes, HMAC-SHA256 signing, automatic retry with exponential backoff, and dead letter queue. No external services.
Postgres backend (default — best for production, transactional outbox):
[]
= "0.1"
= { = "1", = ["full"] }
= "1"
SQLite backend (desktop apps, CLI tools, embedded — no Postgres needed):
[]
= { = "0.1", = ["sqlite"] }
= { = "1", = ["full"] }
= "1"
With SQLite, use SqliteEngine — same API as WebhookEngine:
use SqliteEngine;
let engine = new.await?;
engine.migrate.await?;
// All the same methods: send, broadcast, retry_dead, queue_stats, etc.
How it works
- Your app registers partner webhook endpoints in Postgres or SQLite.
- When an event happens, you call
engine.send()— the event is saved to your database. - The background worker picks it up and POSTs it with an HMAC-SHA256 signature.
- Failures retry with exponential backoff. After
max_attemptsfailures, the event moves to a dead-letter queue.
No Redis, no queuing service, no external infrastructure. Just your existing database.
Quick start
use WebhookEngine;
use json;
use Duration;
async
Transactional outbox
Write your business data and the webhook event in the same database transaction. If the transaction rolls back, the event never exists. If it commits, the event is guaranteed to be delivered.
let mut tx = engine.pool.begin.await?;
// Your business logic
query!
.execute
.await?;
// Webhook in the same transaction — only queued if this tx commits
engine
.send_in_tx
.await?;
tx.commit.await?;
Fan-out to all endpoints at once, atomically:
let mut tx = engine.pool.begin.await?;
engine.broadcast_in_tx.await?;
tx.commit.await?;
All sending methods
| Method | What it does |
|---|---|
send(event_type, payload, endpoint_id) |
Send to one endpoint |
send_in_tx(event_type, payload, endpoint_id, &mut tx) |
Send to one endpoint, inside your transaction |
send_idempotent(event_type, payload, endpoint_id, key) |
Send, deduplicated by key — safe to retry |
send_idempotent_in_tx(event_type, payload, endpoint_id, key, &mut tx) |
Idempotent + transactional |
broadcast(event_type, payload) |
Fan-out to ALL enabled endpoints |
broadcast_in_tx(event_type, payload, &mut tx) |
Fan-out inside your transaction |
broadcast_idempotent(event_type, payload, key) |
Fan-out, deduplicated per endpoint by key |
Validation rules:
event_typemust be non-empty, non-whitespace, ≤ 256 bytes, no control characterspayloadmust be valid JSON, ≤ 1 MB
Return value (WebhookEvent):
WebhookEvent {
id: Uuid, // unique event ID
endpoint_id: Uuid, // which endpoint this is for
event_type: "order.created",
payload: {"id": 1001, "total": 49.99},
status: Pending, // Pending | Delivering | Delivered | Failed | Dead
attempts: 0,
scheduled_at: "2026-01-01T00:00:00Z",
delivering_since: None,
idempotency_key: None,
created_at: "2026-01-01T00:00:00Z",
}
Idempotency keys
Protect against double-sends when your code retries on network errors. The same key for the same endpoint always returns the same event, no matter how many times you call it.
// First call: creates the event
let ev1 = engine.send_idempotent.await?;
// Second call (e.g. after a retry): returns the SAME event
let ev2 = engine.send_idempotent.await?;
assert_eq!; // same event, not a duplicate
Key is scoped per (endpoint_id, key). The same key is independent across different endpoints,
which makes broadcast_idempotent safe to call multiple times.
Running the worker
// Blocks forever — put this at the end of main()
engine.run.await;
// Graceful shutdown — current batch drains before exit
engine.run_graceful.await;
// One cycle — useful for testing or cron-style invocation
let delivered_count: usize = engine.run_once.await?;
What happens in each cycle:
- Reset events stuck in
deliveringstate for longer thanstuck_timeout(crash recovery) - Claim a batch of due events with
SELECT FOR UPDATE SKIP LOCKED - Deliver each event concurrently via HTTP POST with HMAC signature
- Record success or failure; schedule retry or move to DLQ
Endpoint management
// Register
let ep = engine.register.await?;
// Register with full config
let ep = engine.register_with.await?;
// Fetch one
let ep: = engine.endpoint.await?;
// List all (ordered by created_at)
let all: = engine.list_endpoints.await?;
// Paginated list
let page: = engine.list_endpoints_paged.await?; // limit=20, offset=0
// Update (only fields you set are changed)
let updated = engine.update_endpoint.await?;
// Enable / disable (stops the worker from delivering to this endpoint)
engine.disable_endpoint.await?;
engine.enable_endpoint.await?;
// Delete (cascade-deletes all events for this endpoint)
engine.delete_endpoint.await?;
Validation rules:
urlmust behttp://orhttps://and not point to a private/loopback/link-local addresssigning_secretmust be ≥ 16 charactersmax_attemptsmust be ≥ 1initial_delay_msmust be ≥ 1
Endpoint fields:
Endpoint {
id: Uuid,
url: "https://partner.com/hooks",
signing_secret: "your-secret",
description: Some("Partner A"),
enabled: true,
max_attempts: 10,
initial_delay_ms: 1000,
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z", // updated automatically by DB trigger
}
Monitoring & queue operations
// Count events by status — one DB query
let stats: QueueStats = engine.queue_stats.await?;
// QueueStats { pending: 42, delivering: 3, failed: 1, dead: 0, delivered: 1500 }
// Events for one endpoint with a specific status (paginated, newest first)
let failed: = engine
.events_by_status
.await?;
// Events across ALL endpoints (for global monitoring dashboards)
let all_failed: = engine
.events_global
.await?;
// Full delivery attempt history for one event
let log: = engine.delivery_log.await?;
// DeliveryAttempt { attempted_at, response_status: Some(500), duration_ms: Some(243), error: Some("HTTP 500"), success: false }
// Get one event by ID
let ev: = engine.event.await?;
Dead letter queue
When an event exceeds max_attempts failures it moves to status = Dead.
// List dead events for one endpoint (all, unordered)
let dead: = engine.dead_events.await?;
// Paginated (newest first)
let page: = engine.dead_events_paged.await?;
// Requeue one event (resets attempts to 0)
engine.retry_dead.await?;
// Returns HooksmithError::InvalidState if event is not dead
// Returns HooksmithError::EventNotFound if event doesn't exist
// Requeue all dead events for one endpoint — returns count requeued
let requeued: u64 = engine.retry_all_dead.await?;
Cleanup
Delivered and dead events accumulate indefinitely unless cleaned up.
use Duration;
// Delete delivered events older than 7 days — returns count deleted
let removed = engine.cleanup_delivered.await?;
// Delete dead events older than 30 days — returns count deleted
let removed = engine.cleanup_dead.await?;
Only the specified status is touched — pending, delivering, and failed events are never deleted by cleanup.
Crash recovery
The worker calls this automatically every cycle. You can also call it manually for ops use:
// Reset events stuck in 'delivering' for > 120 seconds (returns count reset)
let reset = engine.recover_stuck_deliveries.await?;
What is sent to the endpoint
The worker sends an HTTP POST with:
POST https://partner.example.com/webhooks
Content-Type: application/json
x-hooksmith-signature: v1,<hex-encoded-hmac-sha256>
x-hooksmith-timestamp: 1735689600
x-hooksmith-event-id: 550e8400-e29b-41d4-a716-446655440000
x-hooksmith-event-type: order.created
{"id": 1001, "total": 49.99}
Signature format (Svix-compatible):
The HMAC-SHA256 is computed over {timestamp}.{body} using your signing secret.
Verifying on the receiving side with webhooksmith-axum:
use ;
use ;
async
let app: Router = new
.route
.layer;
Verifying manually without the axum crate:
use signing;
Retry behaviour
Failed deliveries are retried with exponential backoff and full jitter:
| Attempt | Max delay |
|---|---|
| 1st retry | initial_delay_ms × 2 (default: 2s) |
| 2nd retry | initial_delay_ms × 4 (default: 4s) |
| … | … |
| Any | Capped at 1 hour |
After max_attempts failures (default: 10), the event moves to status = Dead and is not retried again automatically. Use retry_dead or retry_all_dead to requeue.
HTTP responses treated as failure: anything outside 2xx. 3xx redirects are explicitly not followed.
Configuration
let engine = builder
.database_url
// OR: .pool(existing_sqlx_pool)
// Worker
.batch_size // events per cycle (default: 50, min: 1)
.poll_interval // idle sleep (default: 500ms)
.http_timeout // per-request timeout (default: 30s)
.stuck_timeout // reaper threshold (default: 120s)
// http_timeout MUST be < stuck_timeout (panics otherwise)
// Postgres pool
.max_connections // pool size (default: 20)
.acquire_timeout // connection wait limit (default: 10s)
// Development only — skips SSRF URL validation
// .allow_insecure_urls()
.build
.await?;
SSRF protection
The following URL targets are blocked at endpoint registration and at delivery time (DNS rebinding protection):
- Loopback:
127.x.x.x,::1,localhost - Private IPv4:
10.x,172.16–31.x,192.168.x - Link-local:
169.254.x.x(AWS metadata endpoint),fe80::/10 - CGNAT:
100.64.0.0/10 - IPv6 unique local:
fc00::/7 - IPv4-mapped IPv6:
::ffff:10.x.x.x, etc.
HTTP redirects are blocked at delivery time (the redirect target is not visited).
Error types
use HooksmithError;
match result
Database schema
engine.migrate() creates three tables:
webhook_endpoints -- registered delivery targets
webhook_events -- outbound events, one row per (endpoint, event)
webhook_delivery_attempts -- log of every HTTP call made
All tables use UUIDs as primary keys. Postgres uses TIMESTAMPTZ; SQLite stores timestamps as ISO 8601 TEXT.
The webhook_events table has a partial index on (status, scheduled_at) for efficient worker queries.
How-to examples
# Postgres examples need Docker:
# Minimal setup — connect, register, send, deliver
# Transactional outbox — atomic writes, rollback safety
# Full axum integration — engine in State, graceful shutdown
# Multi-tenant — per-customer endpoints, broadcast, idempotent sends
# Monitoring — queue stats, DLQ alerts, retry failed events, cleanup
# Full end-to-end demo with real axum receiver
SQLite examples need no setup — just pass a file path or sqlite::memory:.
Requirements
- Rust 1.75+
- Postgres backend (default): Postgres 14+ (
gen_random_uuid(),FOR UPDATE SKIP LOCKED, partial unique indexes, triggers) - SQLite backend: SQLite 3.35+ (supports
RETURNING; WAL mode enabled automatically)
License
MIT OR Apache-2.0