Webhook ingestion (Stripe events, GitHub PRs, Twilio messages, vendor callbacks) is the most common production trigger for the workflows Harvest runs. This chapter wires a Stripe webhook straight into a durable workflow start, in under 50 lines of your own code.

Who verifies what

Harvest does not ship its own signature verification, timestamp tolerance, or replay protection. Autumn's [security.webhooks] layer (autumn_web::webhook::SignedWebhook) already does that — for Stripe, GitHub, Slack, and generic HMAC providers — with secret rotation and a boot-time check that a production deployment can't start with a missing or weak secret. Harvest's #[webhook] macro sits entirely downstream: it maps an already-verified delivery to a deterministic workflow trigger and dispatches it idempotently.

1. Configure the endpoint

TOML
# autumn.toml
[[security.webhooks.endpoints]]
name = "stripe"
path = "/hooks/stripe"
provider = "stripe"
secret_env = "STRIPE_WEBHOOK_SECRET"
# Harvest's own dedup (the mapping function's deterministic WorkflowId, or
# the SignalsWithStart idempotency key) is durable and survives restarts —
# autumn-web's replay-protection layer is in-memory by default. Recommended
# for Harvest-bound endpoints so a redelivery resolves to the same execution
# (200 + the original workflow_exec_id) instead of a 409.
replay_protection = false

2. Write the mapping function

Rust
use autumn_harvest::prelude::*;

#[derive(serde::Deserialize)]
struct StripeEvent {
    id: String,
    #[serde(rename = "type")]
    event_type: String,
}

#[webhook(path = "/hooks/stripe", starts = "subscription_flow")]
fn map_stripe_event(_ctx: &WebhookCtx, evt: StripeEvent) -> Result<WorkflowId, String> {
    Ok(WorkflowId::new(format!("stripe-{}", evt.id)))
}

ctx: &WebhookCtx carries the already-verified metadata (provider(), delivery_id(), event_type(), the endpoint name); the second parameter is your typed payload, deserialized from the verified body. The function is synchronous — do I/O inside the target workflow, not here. Return the deterministic WorkflowId Harvest should start (or attach to, if it's already running).

3. Wire the plugin

Rust
let app = autumn_web::app()
    .plugin(
        HarvestPlugin::new()
            .workflows(workflows![subscription_flow])
            .webhooks(webhooks![map_stripe_event])
            .worker(WorkerConfig::default())
            .api("/api/harvest"),
    );

HarvestPlugin::build mounts /hooks/stripe as an app-level route (not behind the HarvestPlugin::api_with_auth management-API auth layer — the HMAC signature is the auth for this route) and fails fast at startup if two webhooks declare the same path, or if a trigger targets a workflow you forgot to register.

4. Try it

Shell
BODY='{"id":"evt_123","type":"invoice.payment_succeeded"}'
TS=$(date +%s)
SECRET=$STRIPE_WEBHOOK_SECRET
SIG=$(printf "%s.%s" "$TS" "$BODY" | openssl dgst -sha256 -hmac "$SECRET" | sed 's/^.* //')

curl -X POST http://localhost:8080/hooks/stripe \
  -H "Stripe-Signature: t=$TS,v1=$SIG" \
  -H "Content-Type: application/json" \
  -d "$BODY"
# {"status":"accepted","workflow_exec_id":"...","workflow_id":"stripe-evt_123"}

# Redeliver the identical event — same exec id, no duplicate execution:
curl -X POST http://localhost:8080/hooks/stripe \
  -H "Stripe-Signature: t=$TS,v1=$SIG" \
  -H "Content-Type: application/json" \
  -d "$BODY"
# {"status":"idempotent_replay","workflow_exec_id":"...","workflow_id":"stripe-evt_123"}

A starts webhook dedupes solely on the mapping function's deterministic workflow_id (via the reuse policy on (workflow_name, workflow_id)); any upstream provider Idempotency-Key header on the delivery is deliberately not used as an issue-#808 workflow-start idempotency key (it is the provider's key, not a Harvest start key), so it can neither collapse two distinct events that reuse a provider key nor trip the start-throttle/debounce/ batch mutual-exclusion 400.

Signaling a running workflow instead of starting one

Stripe's invoice.payment_succeeded often needs to reach an already running subscription workflow rather than start a new one. Use signals instead of starts — it atomically start-or-attaches and delivers a signal (the signal_with_start primitive from Chapter 4), keyed by the verified delivery ID:

Rust
#[webhook(
    path = "/hooks/stripe/payments",
    signals = "subscription_flow",
    signal_name = "payment_succeeded",
)]
fn map_payment(_ctx: &WebhookCtx, evt: StripeEvent) -> Result<WorkflowId, String> {
    Ok(WorkflowId::new("subscription-shared-id"))
}

A signals target requires the endpoint to resolve a delivery ID (configured delivery_id_header, or a top-level "id" JSON field — Stripe's default). That ID feeds the signal's idempotency key, but the raw ID is not used verbatim: it's namespaced with the webhook's path and signal_name ({path}:{signal_name}:{delivery_id}) before being handed to signal_with_start. signal_with_start's own dedupe is scoped to (workflow_name, workflow_id, idempotency_key) only — it has no notion of webhook endpoints — so without namespacing, two different #[webhook(signals = ...)] bindings that both target the same (workflow_name, workflow_id) pair (an entity workflow fed by more than one provider) could collide whenever their raw delivery IDs happened to match, silently dropping the second signal as an "idempotent replay" of the first. A starts target needs no delivery ID; its own returned WorkflowId is the idempotency mechanism.

Response shapes

OutcomeStatusBody
Fresh dispatch202 Accepted{"status":"accepted","workflow_exec_id","workflow_id"}
Idempotent redelivery200 OK{"status":"idempotent_replay","workflow_exec_id","workflow_id"}
Verification failed401/400/409/503autumn-web's own structured error (signature/timestamp/replay)
Harvest runtime not started yet (boot window)503{"error_code":"runtime_not_started","error"} — a 5xx so that, with replay protection enabled, autumn-web releases the delivery's reserved replay key instead of permanently consuming it; a provider retry after boot completes is then re-evaluated rather than 409ing as a false duplicate
Body not valid JSON400{"error_code":"parse_failed","error"}
Mapping function rejected the payload400{"error_code":"mapping_rejected","error"}
signals target, no delivery ID resolved400{"error_code":"missing_idempotency","error"}

Metrics and audit

harvest.webhook.received / harvest.webhook.rejected counters (labels path, outcome; path is bounded to your registered #[webhook] bindings) — see docs/telemetry.md. Every dispatch attempt that passes verification writes an audit row (operation = "webhook.trigger"); a failed verification never does (an unauthenticated sender can't manufacture audit writes).

What's out of scope

Vendor-specific verifiers ship with autumn-web (WebhookProvider::Stripe /Github/Slack/Generic), not Harvest — there is no WebhookVerifier trait here. Outbound webhooks (a workflow calling out to an external system) are a plain #[activity]; Harvest also ships a durable outbound-delivery slice behind the same webhooks cargo feature (docs/telemetry.md and examples/ reference it) if you want retries/DLQ for outbound calls too.