Autumn can verify third-party callbacks before your handler runs. The SignedWebhook extractor preserves the exact request bytes, checks the provider signature, applies timestamp tolerance, rejects replayed delivery IDs, and then hands the verified bytes and metadata to your route.

Configure endpoints

Use provider presets under security.webhooks.endpoints.

TOML
[security.webhooks]

[security.webhooks.replay]
# Dev/test default is "memory". Production webhook endpoints should use Redis.
backend = "redis"

[security.webhooks.replay.redis]
url = "redis://redis:6379/0"
key_prefix = "myapp:webhooks:replay"

[[security.webhooks.endpoints]]
name = "stripe"
path = "/webhooks/stripe"
provider = "stripe"
secret_env = "STRIPE_WEBHOOK_SECRET"
previous_secret_envs = ["STRIPE_WEBHOOK_SECRET_PREVIOUS"]
timestamp_tolerance_secs = 300
replay_window_secs = 86400

[[security.webhooks.endpoints]]
name = "github"
path = "/webhooks/github"
provider = "github"
secret_env = "GITHUB_WEBHOOK_SECRET"

Provider presets:

ProviderSignature inputSignature headerTimestampDelivery/event metadata
stripe{timestamp}.{raw_body}Stripe-Signature: t=...,v1=...from signature headerJSON id and type
githubraw bodyX-Hub-Signature-256: sha256=...noneX-GitHub-Delivery, X-GitHub-Event
slackv0:{timestamp}:{raw_body}X-Slack-Signature: v0=...X-Slack-Request-TimestampJSON event_id and type; URL verification falls back to JSON challenge
genericraw bodyX-Webhook-Signature: sha256=...optionalX-Webhook-Delivery, X-Webhook-Event

Secrets can be set directly with secret = "..." for local fixtures, but use secret_env in real deployments. During rotation, move the old value to previous_secrets or previous_secret_envs; new signatures use secret, while old signatures verify until you remove the previous value.

For Slack Events API callbacks, replay protection uses the event_id field in the JSON callback body. URL verification requests do not include event_id, so Autumn uses their challenge value as the one-shot replay key. For Slack-style sources outside Events API, set delivery_id_header and event_type_header explicitly if those identifiers arrive in headers.

Production config validation fails when a configured endpoint has no secret or uses a weak/template value. Apps with no configured signed webhooks are unchanged.

Write a handler

Rust,ignore
use autumn_web::prelude::*;

#[post("/webhooks/stripe")]
async fn stripe(webhook: SignedWebhook) -> AutumnResult<Json<serde_json::Value>> {
    let event: serde_json::Value = webhook.json()?;

    Ok(Json(serde_json::json!({
        "received": true,
        "provider": webhook.provider(),
        "delivery_id": webhook.delivery_id(),
        "event_type": webhook.event_type(),
        "event": event,
    })))
}

SignedWebhook exposes:

  • provider() - stripe, github, slack, or generic
  • endpoint() - configured endpoint name
  • delivery_id() - provider delivery ID when present
  • event_type() - provider event type when present
  • received_at() - server receive time used for tolerance/replay checks
  • raw_body() - exact verified HTTP bytes
  • json<T>() - parse the verified payload after authentication

Error contract

Failures happen before handler business logic:

FailureStatusProblem Details code
Missing signature, malformed signature, malformed timestamp, missing replay ID400 Bad Requestautumn.bad_request
Stale timestamp or mismatched signature401 Unauthorizedautumn.unauthorized
Duplicate delivery ID inside the replay window409 Conflictautumn.conflict
Replay backend unavailable503 Service Unavailableautumn.service_unavailable

Responses use application/problem+json and include type, title, status, detail, instance, code, request_id, and errors.

Raw-body invariant

Signatures are verified against the exact HTTP body bytes. Do not parse JSON or forms before verification. A payload like {"id":"evt_1"} and {"id": "evt_1"} can deserialize to the same value but must produce different HMAC inputs; Autumn rejects the byte-modified request before your handler runs.

Replay protection

Replay protection is enabled by default. Autumn stores provider:endpoint:delivery_id and rejects a second delivery inside replay_window_secs with 409 Conflict.

The default replay backend is memory, which is process-local and suitable for tests, development, and explicitly single-replica deployments. Production validation refuses to start replay-protected webhook endpoints on memory unless you set:

TOML
[security.webhooks.replay]
backend = "memory"
allow_memory_in_production = true

Multi-replica production deployments should use Redis:

TOML
[security.webhooks.replay]
backend = "redis"

[security.webhooks.replay.redis]
url = "redis://redis:6379/0"
key_prefix = "myapp:webhooks:replay"

The Redis backend uses an atomic SET NX EX write, so every replica shares the same delivery-ID claim and Redis expires claims after the configured replay window. Compile autumn-web with the redis feature when selecting this backend.

Logging posture

Do not log signing secrets or full raw payloads. Log the provider, endpoint, delivery ID, event type, status, and request ID. If you need payload diagnostics, log a bounded hash or a redacted subset after verification.

Synchronous versus background work

Do only quick validation and idempotent acceptance in the webhook handler. For slow work such as sending mail, syncing a repository, or updating a billing projection, enqueue a #[job] after verification and return promptly. Jobs are recommended for follow-up processing, but they are not required to accept a signed webhook.

See examples/signed-webhooks/ for runnable fixture tests covering valid, tampered-body, stale-timestamp, bad-signature, and duplicate-delivery cases.