webhooksmith 0.1.0

Production-grade webhook delivery engine with transactional outbox, HMAC signing, and dead letter queue
webhooksmith-0.1.0 has been yanked.
Visit the last successful build: webhooksmith-0.1.2

webhooksmith

Webhook delivery engine for Rust applications backed by Postgres.

webhooksmith stores outgoing webhooks in your existing Postgres database and delivers them via a background worker. No additional infrastructure is required beyond Postgres.

When to use this

  • You need to send webhooks to external endpoints from your application
  • You want delivery guarantees — events are persisted before delivery is attempted
  • You already run Postgres and do not want to add another service
  • You need the transactional outbox pattern: your business data and the webhook event written in the same database transaction, so neither exists without the other

Quick start

[dependencies]
webhooksmith = "0.1"
tokio = { version = "1", features = ["full"] }
serde_json = "1"
use webhooksmith::WebhookEngine;
use serde_json::json;

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let engine = WebhookEngine::builder()
        .database_url("postgres://user:pass@localhost/mydb")
        .build()
        .await?;

    engine.migrate().await?; // creates the required tables

    let endpoint = engine
        .register("https://partner.example.com/webhooks", "your-secret-32-chars-min")
        .await?;

    // Send to one endpoint
    engine.send("order.created", json!({"id": 1}), endpoint.id).await?;

    // Send to all enabled endpoints at once
    engine.broadcast("order.created", json!({"id": 1})).await?;

    // Idempotent send — duplicate calls return the existing event
    engine
        .send_idempotent("order.created", json!({"id": 1}), endpoint.id, "order-1001")
        .await?;

    // Start the delivery worker — blocks until the process exits
    engine.run().await;
}

Transactional outbox

The outbox pattern ensures your business data and the webhook event are written atomically. If the process crashes after the database write but before delivery, the event is not lost — the worker picks it up on the next restart.

let mut tx = engine.pool().begin().await?;

// Your business logic and the webhook in the same transaction
sqlx::query!("INSERT INTO orders (id, total) VALUES ($1, $2)", order_id, total)
    .execute(&mut *tx)
    .await?;

engine
    .send_in_tx("order.created", json!({"id": order_id}), endpoint.id, &mut tx)
    .await?;

tx.commit().await?;
// Webhook is queued only if this commit succeeds

broadcast_in_tx works the same way for fan-out to all endpoints.

Graceful shutdown

The in-flight delivery batch completes before the worker exits. No new batch is claimed after the shutdown signal fires. Events not yet claimed stay in the database and are picked up on next startup.

// Shut down on Ctrl-C or SIGTERM:
engine.run_graceful(async {
    tokio::signal::ctrl_c().await.ok();
}).await;

Idempotency keys

If your code retries on network errors, the same event can be enqueued twice. Idempotency keys prevent this.

// Safe to call multiple times — only one event is created
engine
    .send_idempotent("order.created", payload, endpoint.id, "order-1001-created")
    .await?;

The key is scoped per endpoint, so the same key can be used independently across endpoints (useful with broadcast_idempotent).

Receiving webhooks

webhooksmith-axum provides a tower middleware and axum extractors for verifying incoming webhook signatures. See webhooksmith-axum.

Running the demo

docker compose up -d
cargo run --example demo -p webhooksmith

The demo starts a real axum receiver, sends three events including one via the transactional outbox, simulates a failure and retry, and prints the full delivery log.

Features

  • Postgres-backed persistence — events survive process restarts
  • Transactional outboxsend_in_tx / broadcast_in_tx write atomically with your data
  • Fan-outbroadcast delivers one event per enabled endpoint in a single SQL statement
  • Idempotency keyssend_idempotent / broadcast_idempotent deduplicate per (endpoint, key)
  • Exponential backoff with full jitter — automatic retry on failure
  • Dead-letter queue — events that exhaust retries move to DLQ; retry_dead requeues them
  • Graceful shutdownrun_graceful(signal) drains the current batch before stopping
  • Worker crash recoverydelivering_since timestamp lets the reaper reset stuck events
  • HMAC-SHA256 signing — Svix-compatible v1,<hex> signature format
  • SSRF protection — private IPs, loopback, and link-local addresses blocked at registration
  • Redirect protection — HTTP redirects are not followed during delivery
  • Response body limit — streamed at most 4 KB regardless of response size
  • Multi-worker safeSELECT FOR UPDATE SKIP LOCKED prevents double-processing

Configuration

let engine = WebhookEngine::builder()
    .database_url("postgres://...")        // or .pool(existing_pool)
    .batch_size(100)                       // events per worker cycle (default: 50)
    .poll_interval(Duration::from_secs(1)) // idle sleep between cycles (default: 500ms)
    .build()
    .await?;

Endpoint options:

engine.register_with(webhooksmith::NewEndpoint {
    url: "https://partner.example.com/webhooks".into(),
    signing_secret: "your-secret-min-16-chars".into(),
    description: Some("Partner A".into()),
    max_attempts: Some(10),       // retries before moving to DLQ (default: 10)
    initial_delay_ms: Some(1000), // first retry delay in ms (default: 1000)
}).await?;

Requirements

  • Rust 1.75+
  • Postgres 14+ (uses gen_random_uuid(), FOR UPDATE SKIP LOCKED, partial unique indexes)

License

MIT OR Apache-2.0