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-154The 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-70The 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 inResonate.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 withidalready 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 completedctx.runreturns 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 callshandle.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_idarrives twice. The secondbegin_run("webhook/{event_id}", ...)finds the existing promise and reattaches to it. None ofvalidate_event,charge_card,send_receipt, orupdate_ledgerruns a second time. README:70-85 demonstrates this — after the retry banner, the second POST logs only the receiving line atmain.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 samecharge_idfrom the first run. - The payment processor times out on the first attempt.
charge_cardraisesRuntimeError("Payment processor timeout — will retry")(workflow.py:87-90) whensimulate_crashis true and the attempt counter is 1. The SDK retries thectx.run(charge_card, ...)step.validate_eventis not re-run because its result is already checkpointed; the demo's per-event_charge_attemptscounter (workflow.py:52, 77-78) increments only insidecharge_card. On attempt 2 the function returns a realcharge_idand the workflow proceeds. - The process crashes after
charge_cardsucceeds but beforesend_receiptruns. On restart,process_paymentis replayed.validate_eventandcharge_cardreturn their checkpointed results without re-executing, so the customer is not charged a second time. Execution resumes at thesend_receiptstep. (README:18-19 states this guarantee; the mechanism is thectx.runcheckpoint atworkflow.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, becausebegin_runreturns immediately atmain.py:65and the workflow continues on the SDK's background bridge. Swapping inResonate.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-registersprocess_paymentunder 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_rundedup:main.py:48-73 - Status route via
resonate.get:main.py:76-87 begin_runsemantics at v0.6.7:resonate/resonate.py:463-553(SDK repo, tagv0.6.7— overloads at 463-477, implementation at 478-553)ctx.run(alias ofctx.lfc) at v0.6.7:resonate/resonate.py:863-886(SDK repo, tagv0.6.7)- Resonate docs: https://docs.resonatehq.io
