Durable webhook deduplication keyed by event_id in Python

Webhook providers retry on slow ACKs and network timeouts, so a payment webhook can arrive twice and double-charge the customer unless the handler deduplicates. The Resonate pattern is to use the provider's event_id as the durable promise id: begin_run("webhook/{event_id}", ...) reattaches to the existing execution if it has been seen, and creates a new one if not. This example demonstrates that with a FastAPI receiver, a four-step payment workflow (validate, charge, receipt, ledger), and a --crash mode that fails the charge step on its first attempt to show step-level retry without re-running the earlier steps.

The shape of the solution

def process_payment(
ctx: Context, event: WebhookEvent, simulate_crash: bool
) -> Generator[Any, Any, PaymentResult]:
# Step 1: Validate signature and event structure
yield ctx.run(validate_event, event)

# Step 2: Charge the card — checkpointed.
# If this crashes and retries, we call the payment processor exactly once.
# If a duplicate webhook arrives with the same event_id, this step is
# returned from cache — the processor is never called again.
charge_id = yield ctx.run(charge_card, event, simulate_crash)

# Step 3: Send receipt
yield ctx.run(send_receipt, event, charge_id)

# Step 4: Update accounting ledger
result = yield ctx.run(update_ledger, event, charge_id)

return result
# from example-webhook-handler-py/workflow.py:136-154

The orchestrator is a Python generator. Each yield ctx.run(...) is a durable checkpoint: the SDK records the invocation, executes the leaf, records the result, and only then advances the generator. The dedup is not in this function — it is in the entry point, where the event_id from the request body becomes the promise id:

resonate = Resonate.local()
resonate.register(process_payment)
# ...
resonate.begin_run(
f"webhook/{event['event_id']}",
process_payment,
event,
SIMULATE_CRASH,
)
# from example-webhook-handler-py/main.py:34-35, 65-70

The durable primitives in play

Resonate.local() — constructs a Resonate client backed by an in-memory store; no server process required. The same code points at a Resonate server by swapping in Resonate.remote(...). Source: main.py:34; README:38, :150-160.

resonate.register(process_payment) — registers the generator as an invocable workflow under its function name. Source: main.py:35.

resonate.begin_run(id, func, *args) — non-blocking. If a durable promise with id already exists, the SDK returns a handle to that existing execution instead of starting a second one. This is the deduplication mechanism. Source: main.py:65-70.

ctx.run(fn, *args) — durable local function call. The SDK checkpoints at the invocation and at the result; on replay, a completed ctx.run returns its stored value instead of re-running. Source: workflow.py:140, 146, 149, 152.

resonate.get(id) — fetches the handle for an existing execution. The status route polls this and calls handle.done() / handle.result(). Source: main.py:80-87.

What the SDK handles vs. what you write

The SDK handles: assigning a durable promise to each begin_run and to each ctx.run leaf; detecting that an incoming begin_run id matches an existing promise and returning the existing handle rather than re-executing; persisting each step's result so the workflow resumes from the last completed checkpoint after a crash; retrying a failed step according to the configured retry policy (default exponential backoff) without re-running earlier completed steps.

You write: the generator that yields the steps in their business order; the four leaf functions (validate_event, charge_card, send_receipt, update_ledger); the choice to key the promise id off event_id rather than some other field; the FastAPI route that extracts event_id, calls begin_run, and returns 200 immediately so the provider's 5-second ACK window is not blocked by the workflow. The signature verification stub (workflow.py:62) is also yours — Resonate does not authenticate the upstream caller.

Failure modes covered

The same event_id arrives twice. The second begin_run("webhook/{event_id}", ...) finds the existing promise and reattaches to it. None of validate_event, charge_card, send_receipt, or update_ledger runs a second time. README:70-85 demonstrates this — after the retry banner, the second POST logs only the receiving line at main.py:61 (no further per-step logs), and the Notice line at README:83 makes the guarantee explicit: "validate/charge/receipt/ledger each logged exactly ONCE." The status route returns the same charge_id from the first run.

The payment processor times out on the first attempt. charge_card raises RuntimeError("Payment processor timeout — will retry") (workflow.py:87-90) when simulate_crash is true and the attempt counter is 1. The SDK retries the ctx.run(charge_card, ...) step. validate_event is not re-run because its result is already checkpointed; the demo's per-event _charge_attempts counter (workflow.py:52, 77-78) increments only inside charge_card. On attempt 2 the function returns a real charge_id and the workflow proceeds.

The process crashes after charge_card succeeds but before send_receipt runs. On restart, process_payment is replayed. validate_event and charge_card return their checkpointed results without re-executing, so the customer is not charged a second time. Execution resumes at the send_receipt step. (README:18-19 states this guarantee; the mechanism is the ctx.run checkpoint at workflow.py:146.)

The process crashes inside the FastAPI handler after the workflow was started. Coverage depends on the store. In the demo's Resonate.local() mode (main.py:34) the promise lives in an in-memory store, so a hard process crash wipes it and the workflow does not resume — only handler-thread failures inside a living process survive, because begin_run returns immediately at main.py:65 and the workflow continues on the SDK's background bridge. Swapping in Resonate.remote(...) against a server-backed store (README:150-160 documents the swap) persists the promise across process restart; when a new process boots and re-registers process_payment under the same name, the in-flight promise is resumed from its last checkpoint.

When to reach for this pattern

If you are receiving webhooks from a provider that retries on timeout (Stripe, GitHub, Shopify, Twilio) and you need exactly-once business effects without maintaining a dedup table or distributed lock.

If a request handler must ACK within seconds but the work behind it spans multiple side-effecting steps that you want resumed across crashes.

If you have a natural, provider-supplied unique id per event (e.g. event_id, delivery_id, message id) that can serve as the durable promise id.

If you want step-level retry on a single flaky dependency without rerunning the cheaper upstream steps that already succeeded.

If you want to start in-process (Resonate.local()) for development and later move to a server-backed deployment without changing the workflow code.

Sources

Example repo: https://github.com/resonatehq-examples/example-webhook-handler-py

Resonate Python SDK: https://github.com/resonatehq/resonate-sdk-py

Pinned SDK version: resonate-sdk>=0.6.7 (pyproject.toml:10)

Workflow generator: workflow.py:136-154

Step handlers (validate_event, charge_card, send_receipt, update_ledger): workflow.py:59-129

FastAPI receiver + begin_run dedup: main.py:48-73

Status route via resonate.get: main.py:76-87

begin_run semantics at v0.6.7: resonate/resonate.py:463-553 (SDK repo, tag v0.6.7 — overloads at 463-477, implementation at 478-553)

ctx.run (alias of ctx.lfc) at v0.6.7: resonate/resonate.py:863-886 (SDK repo, tag v0.6.7)

Resonate docs: https://docs.resonatehq.io