Your database transaction rolled back. Did your webhook fire anyway?

rust dev.to

You've probably shipped this bug without knowing it.

Your app processes an order. It inserts a row into the database, fires a webhook to your partner's API, and both succeed. Except then something goes wrong — a constraint violation, a network error, your own code throwing an exception — and the database transaction rolls back.

The order never existed. But the webhook already fired.

Your partner received a notification for a ghost order. They shipped a package to a customer who never paid. You have no record that any of this happened.

This is the phantom webhook problem. It happens silently, in production, and most webhook implementations have it.


Why it happens

The standard webhook implementation looks like this:

1. Begin transaction
2. INSERT order INTO orders
3. Commit transaction
4. POST /webhook to partner  ← happens AFTER commit
Enter fullscreen mode Exit fullscreen mode

Seems safe, right? The commit succeeded before the webhook fires.

But there are three failure modes nobody thinks about:

Failure mode 1: The crash window

1. INSERT order ──────────────────→ success
2. COMMIT ────────────────────────→ success
3. [process crashes / pod restarts]
4. POST /webhook ─────────────────→ never happens
Enter fullscreen mode Exit fullscreen mode

The order exists in your database. The webhook never fired. Your partner never knows about it.

Failure mode 2: The timeout race

1. INSERT order ──────────────────→ success
2. COMMIT ────────────────────────→ success
3. POST /webhook ─────────────────→ timeout (partner slow)
4. Retry POST /webhook ───────────→ success (duplicate!)
Enter fullscreen mode Exit fullscreen mode

You sent the webhook twice. Your partner charged the customer twice.

Failure mode 3: The silent rollback

1. INSERT order ──────────────────→ success
2. INSERT order_items ────────────→ constraint violation ← second insert fails
3. ROLLBACK ──────────────────────→ order gone from DB
4. [but you already called POST /webhook in step 1.5]
Enter fullscreen mode Exit fullscreen mode

This one happens when the webhook call is buried inside a larger transaction that later rolls back. The webhook fired for data that doesn't exist.


The transactional outbox pattern

The fix is to stop treating the webhook as a separate side effect and make it part of the same atomic operation as your business data.

❌ What you're doing now:

  ┌─────────────────────────────┐
  │  Transaction                │
  │  ├── INSERT order           │
  │  └── COMMIT                 │
  └─────────────────────────────┘
         │
         └── POST /webhook  ← outside the transaction
                               can fail independently
                               can never happen
                               can happen twice


✅ Transactional outbox:

  ┌─────────────────────────────────────────┐
  │  Transaction                            │
  │  ├── INSERT order                       │
  │  ├── INSERT webhook_event  ← same tx   │
  │  └── COMMIT (or ROLLBACK atomically)    │
  └─────────────────────────────────────────┘
         │
         └── Background worker (always running)
                │
                ├── reads webhook_events table
                ├── POST /webhook
                ├── retries on failure (exponential backoff)
                └── dead letter queue after N failures
Enter fullscreen mode Exit fullscreen mode

Either both the order and the webhook event exist — or neither does. The background worker handles the actual HTTP call separately, with retry logic and a full audit log.

This pattern has a name — the transactional outbox — and it's been standard practice in Java and Go shops for years. The Rust ecosystem didn't have a library for it until now.


webhooksmith: drop-in transactional outbox for Rust

I built webhooksmith to solve this for Rust + Postgres apps. Add it to your project:

[dependencies]
webhooksmith = "0.1"
tokio = { version = "1", features = ["full"] }
serde_json = "1"
Enter fullscreen mode Exit fullscreen mode

Setup takes about 10 lines:

use webhooksmith::WebhookEngine;
use serde_json::json;

let engine = WebhookEngine::builder()
    .database_url("postgres://user:pass@localhost/mydb")
    .build()
    .await?;

engine.migrate().await?;  // creates 3 tables, safe to call on startup

let endpoint = engine
    .register("https://partner.example.com/webhooks", "your-secret-key")
    .await?;
Enter fullscreen mode Exit fullscreen mode

Here's the outbox pattern with webhooksmith:

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

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

// Webhook in the SAME transaction
engine
    .send_in_tx("order.created", json!({"id": order_id, "total": total}), endpoint.id, &mut tx)
    .await?;

tx.commit().await?;
// If this commit succeeds: order exists AND webhook is queued
// If this commit fails: order doesn't exist AND webhook was never queued
// No phantom webhooks. No silent drops.
Enter fullscreen mode Exit fullscreen mode

Start the background worker at the end of your main():

// Delivers events, retries failures, moves exhausted events to DLQ
engine.run().await;

// Or gracefully drain before shutdown:
engine.run_graceful(async {
    tokio::signal::ctrl_c().await.ok();
}).await;
Enter fullscreen mode Exit fullscreen mode

What happens when delivery fails

The worker retries with exponential backoff. If a partner endpoint is down for 6 hours, events wait in the database and are delivered when it comes back — without any code from you.

Attempt 1  ──→ partner returns 503 ──→ retry in ~2s
Attempt 2  ──→ partner returns 503 ──→ retry in ~4s
Attempt 3  ──→ partner returns 503 ──→ retry in ~8s
...
Attempt 10 ──→ partner returns 503 ──→ moves to dead letter queue
Enter fullscreen mode Exit fullscreen mode

You can inspect the DLQ and requeue events:

// See what failed
let dead = engine.dead_events(endpoint.id).await?;
for event in &dead {
    println!("{}: {} attempts", event.event_type, event.attempts);
}

// Requeue all of them
let requeued = engine.retry_all_dead(endpoint.id).await?;
println!("Requeued {} events", requeued);
Enter fullscreen mode Exit fullscreen mode

There's also a delivery log for every event — you can see exactly what was sent, when, the HTTP response status, and how long each attempt took.


Sending to multiple partners at once

If you have multiple endpoints (multiple partners, multiple environments), broadcast delivers to all of them atomically:

// One call = one event per endpoint, all in one SQL statement
let events = engine.broadcast_in_tx("order.created", json!({"id": order_id}), &mut tx).await?;
println!("Queued {} webhooks across {} endpoints", events.len(), events.len());
Enter fullscreen mode Exit fullscreen mode

Idempotency for retrying callers

If your own code retries on network errors, you might call send twice for the same event. Idempotency keys prevent duplicates:

// Safe to call multiple times — only one webhook is ever created
engine
    .send_idempotent("order.created", payload, endpoint.id, "order-1001-created")
    .await?;
Enter fullscreen mode Exit fullscreen mode

The second call returns the same event that was created on the first call. The partner receives exactly one webhook.


What gets sent to the partner

The worker sends an HTTP POST with HMAC-SHA256 signatures that partners can verify:

POST https://partner.example.com/webhooks
Content-Type: application/json
x-webhooksmith-signature: v1,a3f4b2...
x-webhooksmith-timestamp: 1735689600
x-webhooksmith-event-id: 550e8400-e29b-41d4-a716-446655440000
x-webhooksmith-event-type: order.created

{"id": 1001, "total": 49.99}
Enter fullscreen mode Exit fullscreen mode

If you're also building the receiving side with axum, webhooksmith-axum verifies signatures automatically:

use axum::{Router, routing::post, http::StatusCode};
use webhooksmith_axum::{WebhookSecretLayer, VerifiedWebhook};

async fn receive(VerifiedWebhook(payload): VerifiedWebhook) -> StatusCode {
    println!("{}: {:?}", payload.event_type, payload.body);
    StatusCode::OK
}

let app: Router = Router::new()
    .route("/webhooks", post(receive))
    .layer(WebhookSecretLayer::new("your-secret-key"));
Enter fullscreen mode Exit fullscreen mode

The extractor automatically rejects replayed requests (5-minute timestamp window), requests with wrong signatures, and bodies over 1 MB.


Monitoring queue health

let stats = engine.queue_stats().await?;
println!("pending={} delivering={} failed={} dead={}",
    stats.pending, stats.delivering, stats.failed, stats.dead);
Enter fullscreen mode Exit fullscreen mode

Set up an alert if dead > 0 — that means events have exhausted all retries and need manual attention.


What it requires

  • Rust 1.75+
  • An existing Postgres 14+ database (the one your app already uses — no new infrastructure)
  • No Redis, no SQS, no external queue service

Get started

cargo add webhooksmith
Enter fullscreen mode Exit fullscreen mode

webhooksmith on crates.io

Source + examples on GitHub

webhooksmith-axum for receiving side

If you're building a SaaS with partner webhooks, or any system where "fire and forget" isn't acceptable, this gives you the outbox pattern in about 10 lines of code.


The ghost order problem is real — I've seen it in production. The outbox pattern is the right fix. This is the implementation I wished existed when I first ran into it.

Source: dev.to

arrow_back Back to Tutorials