Autumn has first-class support for outbound signed webhook delivery, allowing your application to dispatch structured event payloads to external API consumers safely and reliably.
Architectural Overview
The outbound webhook subsystem consists of five core components:
WebhookSubscription: Represents a consumer's registered endpoint, signing secret, the event topics they are interested in, and their active status.OutboundWebhookStore: A pluggable trait for persisting subscriptions and tracking delivery attempts. There is no default —OutboundWebhookPlugin::new(store)takes the store as a required argument, so you always choose one explicitly.InMemoryOutboundWebhookStoreships with the framework, but it is process-local AND unbounded: its subscriptions and delivery logs are lost on restart, are not shared between replicas, and accumulate with no cap or eviction for as long as the process runs. It is for tests and local development. Any long-running app needs a durable shared implementation of the trait, for example the built-inSqlOutboundWebhookStore(see §2). (OutboundWebhookStoreandInMemoryOutboundWebhookStoreare aliases kept for compatibility; the current names areOutboundWebhookHandlerandInMemoryOutboundWebhookHandler.)WebhookOutboundManager: The central coordinator available viaAppStateextensions, providing the.dispatch()method, which writes a delivery-log row per subscription and then enqueues the delivery job. The two steps are ordered, not atomic: no transaction spans the pluggable store and the job queue, so a crash between them can leave a logged event that was never enqueued. This is not an outbox guarantee, and it cannot be turned into one by reconciling after the fact — a successful enqueue writes no marker on the log row, so a row that was enqueued is indistinguishable from one whose process died first. A sweeper over those rows must either re-enqueue (duplicating deliveries) or skip (dropping them); it cannot tell which is correct. Implementing the trait does not close this window.OutboundWebhookHandlerexposes storage methods only;dispatch()callslog_deliveryand performs the enqueue afterwards, outside the trait, so no implementation can bring the enqueue into its own transaction. To close the window, write the dispatch to the transactional outbox withdispatch_in_tx(see below). Three more facts bear on any attempt to build a stronger guarantee on top of this, and all three are easy to get wrong:
- A successful
dispatch()does not mean the delivery is durable. On the defaultjobs.backend = "local"the job queue is in-process and explicitly non-durable — a crashed process loses the queue — soOkmeans only "handed to a queue that may not survive a restart". A durable job backend (postgresorredis) and a durableOutboundWebhookHandlerare both preconditions for reasoning about loss at all. - Each delivery has a stable, signed id. Autumn sends the Standard Webhooks headers
webhook-id,webhook-timestampandwebhook-signature.webhook-idis the delivery log id. Every retry of one delivery sends the samewebhook-id, so a receiver can drop a copy.webhook-signaturesigns the id, the timestamp and the body, so a replay with a new id fails the check. Autumn calculates theAutumn-Signaturet=value again for each attempt. Do not use it to find copies. (Other headers can be present. Undertelemetry-otlp, the shared HTTP client adds W3Ctraceparent/tracestate.) - Retries can duplicate an event only after the job is enqueued. Before that point the loss window above applies, so
dispatch()is neither at-least-once nor at-most-once on its own. Idempotent receivers protect you from duplicates. The outbox (below) protects you from loss between commit and dispatch.
To prevent loss between commit and dispatch, use the outbox. manager.dispatch_in_tx(&state, conn, topic, &payload) (or outbox.dispatch_webhook) writes the dispatch to autumn_outbox on your transaction connection. The relay calls dispatch after commit, and sends again until the enqueue works. The delivery id comes from the outbox message id and the subscription id. A second relay send skips a delivery that has a result. Before the first result, it can enqueue the delivery again, with the same webhook-id. Delivery after the enqueue depends on the job backend and the store. See Transactional Outbox and Inbox.
This page deliberately stops short of prescribing an exactly-once design. Getting one right depends on the job backend, the handler implementation, and how the application sequences its own transaction against dispatch() — and each of those changes the answer. If you need that guarantee, treat the points above as the constraints to design against, and take the design itself to the maintainers rather than inferring it from this page. One case is handled for you, on the default path only: when dispatch() falls back to enqueuing the autumn_webhook_delivery job and that enqueue fails, the log row is marked is_dlq and can be replayed from the DLQ endpoints below. If a WebhookDelegateExt is installed, dispatch() hands the delivery to that delegate instead of enqueuing, and a delegate error is returned to the caller without marking the row — so a failed delegated delivery never appears in the DLQ, and an operator looking there will not find it.
4. autumn_webhook_delivery Job: A resilient background job that handles HTTP POST delivery, computes payload signatures, executes retries, and handles deactivations.
5. Actuator Operations: Sensitive API endpoints under /actuator/webhooks/* for monitoring the Dead Letter Queue (DLQ) and replaying permanently failed deliveries.
1. Webhook Subscriptions
A subscription is registered for a set of event topics and points to a target destination URL:
use autumn_web::webhook_outbound::{WebhookSubscription, WebhookSubscriptionStatus};
let subscription = WebhookSubscription {
id: "sub_123".to_owned(),
target_url: "https://api.consumer.com/webhooks/receiver".to_owned(),
event_topics: vec!["order.created".to_owned(), "order.fulfilled".to_owned()],
secret: "whsec_stripe_style_signing_secret_key_32_bytes!!".to_owned(),
status: WebhookSubscriptionStatus::Active,
consecutive_failures: 0,
};
Subscription Statuses
Active: The subscription is operational; events will be dispatched immediately.Disabled: The subscription is manually turned off by the operator or consumer.Failed: The subscription has been automatically deactivated after exceeding the maximum failure threshold (50 consecutive failures) to protect your application resources and avoid thundering herd requests on failing external servers.
2. Pluggable Storage Backend
To support diverse hosting environments, the persistence layer is abstracted behind the OutboundWebhookStore trait. You can implement this trait to store subscription states and delivery logs in PostgreSQL, Redis, MongoDB, or any external service.
Autumn ships InMemoryOutboundWebhookStore—a thread-safe, in-memory implementation—but it is not a default: OutboundWebhookPlugin::new(store) requires you to name a store, so choosing it is always explicit. Because it is process-local, its subscriptions and delivery logs are lost on restart and are not shared between replicas.
It is also unbounded. Subscriptions and delivery logs are held in plain hash maps with no capacity limit and no eviction. A retry does not add a row — the job reuses the original log id and log_delivery replaces that entry in place, advancing its attempt counter, so only the latest attempt of each delivery is retained and the actuator shows that rather than a per-attempt history. What does grow is dispatches: each one inserts a row per matching subscription, and nothing ever removes them. Memory therefore grows with dispatch volume for the lifetime of the process. Use it for tests and local development. A long-running app of any kind needs a durable implementation of the trait, as does anything with more than one replica or that must survive a deploy.
Durable store: SqlOutboundWebhookStore
SqlOutboundWebhookStore keeps subscriptions and delivery logs in the app database (Postgres or SQLite). They survive a restart, and all replicas see them. It counts failures as the in-memory store does.
use autumn_web::webhook_outbound::OutboundWebhookPlugin;
// A store on the app pool. At startup it creates the tables if they do not exist.
let app = autumn_web::app().plugin(OutboundWebhookPlugin::sql());
To use another pool, build the store yourself: OutboundWebhookPlugin::new(Arc::new(SqlOutboundWebhookStore::new(pool))), and call ensure_schema (or apply WEBHOOK_SCHEMA_SQL before the deploy).
The tables are autumn_webhook_subscriptions and autumn_webhook_deliveries. The secret column holds each signing secret. Limit read access to it. Nothing deletes delivery logs for you: call purge_deliveries_before(cutoff) on a schedule. It deletes only delivered (2xx) logs: pending and retrying deliveries, DLQ logs, and logs of deliveries skipped for a disabled or failed subscription stay.
3. Stripe-Style Payload Signing
Security is established using Stripe-style HMAC-SHA256 payload signing. Every request body is signed using the subscription's secret key and sent via the Autumn-Signature header in the format:
Autumn-Signature: t=1778930400,v1=a1b2c3d4e5f6...
t: The Unix epoch timestamp of the delivery dispatch.v1: The computed HMAC-SHA256 hex signature of the string{timestamp}.{raw_body}.
Each request also carries:
webhook-id: 0b6f1c2e-6a8e-4d0a-9d55-6c1b2f6a7e10
webhook-timestamp: 1778930400
webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4=
webhook-id: The delivery id. It is the same on every retry. Store it, and drop a request whose id you already processed.webhook-timestamp: The same value ast.webhook-signature:v1,and the base64 HMAC-SHA256 of{webhook-id}.{webhook-timestamp}.{raw_body}(Standard Webhooks). For awhsec_secret, the key is the base64-decoded part after the prefix, as the spec says. For another secret, the key is its raw bytes. Standard Webhooks libraries verify it when the secret uses thewhsec_form.
Verification (Consumer side)
The consumer receives the header, extracts t and v1, concatenates t and the raw request body bytes with a ., computes the HMAC-SHA256 using their registered secret, and compares it securely with v1 to prevent timing attacks.
4. Retries, Jitter, and the Dead Letter Queue (DLQ)
If a webhook delivery fails (network exception, connection timeout, or a non-2xx HTTP status code), the background job initiates a robust retry flow:
- Exponential backoff with full jitter. Every backend (
local,postgres,redis,sqlite) waits a random time in[0, min(cap, initial_backoff_ms * 2^(attempt-1))]before a retry (job_retry_delay_msinautumn/src/job.rs, which usesautumn_web::backoff). With the default 1000 ms base, the ceilings are 1 s, 2 s, 4 s, 8 s. The jitter stops deliveries that fail together from retrying together. The cap is[jobs] max_backoff_ms(default 1 h). - Capped attempts. A delivery has 5 attempts by default. Change this with
OutboundWebhookPlugin::with_max_attempts(n). A lower limit also applies to a delivery that already waits for a retry: when it has used up the new limit, it goes to the DLQ without another request. A manual DLQ replay is not limited. - Dead Letter Queue (DLQ): If all attempts fail, the delivery log is permanently archived as
is_dlq = trueand retired from active background processing.
5. Actuator Observability and Replaying
Operators can monitor the state of outbound webhooks and manually trigger re-deliveries of DLQ logs using the sensitive actuator routes:
List DLQ Deliveries
Retrieve all permanently failed webhook delivery attempts:
GET /actuator/webhooks/dlq
Response (JSON):
[
{
"id": "log_456",
"subscription_id": "sub_123",
"topic": "order.created",
"payload": "{\"order_id\":\"ord_999\"}",
"request_headers": {
"Content-Type": "application/json",
"Autumn-Signature": "t=1778930400,v1=..."
},
"response_status": 500,
"response_body": "{\"error\":\"Internal Server Error\"}",
"elapsed_ms": 142,
"attempt": 5,
"max_attempts": 5,
"is_dlq": true,
"last_error": "server returned status: 500",
"timestamp": "2026-05-26T05:00:00Z"
}
]
Replay a DLQ Log
Manually reset and trigger re-delivery of a dead-lettered log. The system will reset the attempt counter back to 1, mark the log as no longer in the DLQ, and re-enqueue a fresh background delivery task:
POST /actuator/webhooks/replay
Content-Type: application/json
{
"log_id": "log_456"
}
6. AppBuilder Integration
To enable outbound webhooks, configure your store and register the OutboundWebhookPlugin in your application setup:
use std::sync::Arc;
use autumn_web::prelude::*;
use autumn_web::webhook_outbound::{InMemoryOutboundWebhookStore, OutboundWebhookPlugin};
#[autumn_web::main]
async fn main() {
let store = Arc::new(InMemoryOutboundWebhookStore::new());
let webhook_plugin = OutboundWebhookPlugin::new(store.clone())
.with_initial_backoff_ms(1000); // 1s base retry backoff
autumn_web::app()
.plugin(webhook_plugin)
.run()
.await;
}
7. Dispatching Events
Within your HTTP handlers or workflow tasks, extract the WebhookOutboundManager from application extensions to dispatch structured payloads:
use autumn_web::prelude::*;
use autumn_web::webhook_outbound::WebhookOutboundManager;
#[post("/orders")]
async fn create_order(
state: State<AppState>,
Json(payload): Json<CreateOrderPayload>,
) -> AutumnResult<Json<Order>> {
let order = save_order_to_db(&payload).await?;
// Fetch the manager from app extensions
let manager = state.extension::<WebhookOutboundManager>()
.ok_or_else(|| AutumnError::internal_server_error_msg("Webhook outbound subsystem not registered"))?;
// Logs a delivery row and enqueues a delivery job for each matching active
// subscriber. The two steps are not atomic: a crash between them can drop
// the event, and no retry will recover it. Once enqueued, retries can
// deliver more than once, so receivers must be idempotent.
manager.dispatch(&state, "order.created", &order).await?;
Ok(Json(order))
}